From 05f56017742ab073d29007c52a7d46bf555fb7d9 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 17 Mar 2024 15:44:55 +1000 Subject: [PATCH 01/52] add translations route for js translations --- warehouse/routes.py | 1 + warehouse/views.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/warehouse/routes.py b/warehouse/routes.py index f104927976a4..7def83288dfb 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -28,6 +28,7 @@ def includeme(config): # Basic global routes config.add_route("index", "/", domain=warehouse) config.add_route("locale", "/locale/", domain=warehouse) + config.add_route("translation", "/translation/", domain=warehouse) config.add_route("robots.txt", "/robots.txt", domain=warehouse) config.add_route("opensearch.xml", "/opensearch.xml", domain=warehouse) config.add_route("index.sitemap.xml", "/sitemap.xml", domain=warehouse) diff --git a/warehouse/views.py b/warehouse/views.py index 4c17a5f6c266..e5cc1bd5eb04 100644 --- a/warehouse/views.py +++ b/warehouse/views.py @@ -297,6 +297,36 @@ def locale(request): return resp +@view_config( + route_name="translation", + renderer="json", + request_method="GET", + accept="application/json", + has_translations=True) +def translation(request): + singular = request.params.get("s", None) + plural = request.params.get("p", None) + num = request.params.get("n", None) + domain = "messages" + values = request.params.mixed() + + if not singular: + request.log.info(f"translation params '${request.params}'") + raise HTTPBadRequest("Message singular must be provided as 's'.") + + localizer = request.localizer + localizer_kwargs = {'domain': domain, 'mapping': values} + if plural is None or num is None: + msg = localizer.translate(singular, **localizer_kwargs) + else: + msg = localizer.pluralize(singular, plural, int(num), **localizer_kwargs) + + request.log.info(f"translation params '${request.params}'; msg '${msg}'; " + f"localizer locale '${localizer.locale_name}'.") + return {'msg': msg} + + + @view_config( route_name="classifiers", renderer="pages/classifiers.html", has_translations=True ) @@ -325,7 +355,7 @@ def search(request): metrics.increment("warehouse.views.search.error", tags=["error:query_too_long"]) raise HTTPRequestEntityTooLarge("Query string too long.") - order = request.params.get("o", "") + ordr = request.params.get("o", "") classifiers = request.params.getall("c") query = get_es_query(request.es, querystring, order, classifiers) From ece9fb5327640310d93372db309b1239fd80dfe6 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 17 Mar 2024 15:45:10 +1000 Subject: [PATCH 02/52] use js translations for timeago --- .../static/js/warehouse/utils/timeago.js | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/warehouse/static/js/warehouse/utils/timeago.js b/warehouse/static/js/warehouse/utils/timeago.js index fafb94506410..d49a700c7523 100644 --- a/warehouse/static/js/warehouse/utils/timeago.js +++ b/warehouse/static/js/warehouse/utils/timeago.js @@ -11,6 +11,8 @@ * limitations under the License. */ +import fetchGetText from "warehouse/utils/fetch-gettext"; + const enumerateTime = (timestampString) => { const now = new Date(), timestamp = new Date(timestampString), @@ -24,21 +26,19 @@ const enumerateTime = (timestampString) => { return time; }; -const convertToReadableText = (time) => { +const convertToReadableText = async (time) => { let { numDays, numMinutes, numHours } = time; if (numDays >= 1) { - return numDays == 1 ? "Yesterday" : `About ${numDays} days ago`; + return fetchGetText("Yesterday", "About ${numDays} days ago", numDays, {"numDays": numDays}); } if (numHours > 0) { - numHours = numHours != 1 ? `${numHours} hours` : "an hour"; - return `About ${numHours} ago`; + return fetchGetText("an hour", "About ${numHours} ago", numHours, {"numHours": numHours}); } else if (numMinutes > 0) { - numMinutes = numMinutes > 1 ? `${numMinutes} minutes` : "a minute"; - return `About ${numMinutes} ago`; + return fetchGetText("a minute", "About ${numMinutes} ago", numMinutes, {"numMinutes": numMinutes}); } else { - return "Just now"; + return fetchGetText("Just now"); } }; @@ -47,6 +47,11 @@ export default () => { for (const timeElement of timeElements) { const datetime = timeElement.getAttribute("datetime"); const time = enumerateTime(datetime); - if (time.isBeforeCutoff) timeElement.innerText = convertToReadableText(time); + if (time.isBeforeCutoff) { + convertToReadableText(time) + .then((text) => { + timeElement.innerText = text.msg; + }); + } } }; From d70e76e4876d7e905161fd92789fff8fe86dab68 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 17 Mar 2024 15:45:23 +1000 Subject: [PATCH 03/52] add babel config for js translations --- babel.cfg | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/babel.cfg b/babel.cfg index 862cc0457b89..a8d480153cdc 100644 --- a/babel.cfg +++ b/babel.cfg @@ -3,3 +3,9 @@ encoding = utf-8 extensions=warehouse.utils.html:ClientSideIncludeExtension,warehouse.i18n.extensions.TrimmedTranslatableTagsExtension silent=False +[javascript: **.js] +encoding=utf-8 +silent=False +parse_template_string=True +template_string=True +keywords=fetchGetText From 9003fa5cd16d28208a92cb5d45a67e4f24a71525 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 17 Mar 2024 15:45:42 +1000 Subject: [PATCH 04/52] add js util to get translations from backend --- .../js/warehouse/utils/fetch-gettext.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 warehouse/static/js/warehouse/utils/fetch-gettext.js diff --git a/warehouse/static/js/warehouse/utils/fetch-gettext.js b/warehouse/static/js/warehouse/utils/fetch-gettext.js new file mode 100644 index 000000000000..022384724e6c --- /dev/null +++ b/warehouse/static/js/warehouse/utils/fetch-gettext.js @@ -0,0 +1,48 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fetchOptions = { + mode: "same-origin", + credentials: "same-origin", + cache: "default", + redirect: "follow", +}; + +export default (singular, plural, num, values) => { + const partialMsg = `for singular '${singular}', plural '${plural}', num '${num}', values '${JSON.stringify(values || {})}'`; + let searchValues = {s: singular}; + if (plural) { + searchValues.p = plural; + } + if (num !== undefined) { + searchValues.n = num; + } + if (values !== undefined) { + searchValues = {...searchValues, ...values}; + } + const searchParams = new URLSearchParams(searchValues); + return fetch("/translation?" + searchParams.toString(), fetchOptions) + .then(response => { + if (response.ok) { + const responseJson = response.json(); + console.debug(`Fetch gettext success ${partialMsg}: ${responseJson}.`); + return responseJson; + } else { + console.warn(`Fetch gettext unexpected response ${partialMsg}: ${response.status}.`); + return ""; + } + }).catch((err) => { + console.error(`Fetch gettext failed ${partialMsg}: ${err.message}.`); + return ""; + }); +}; From d2273a4edc82909d0a8bc14baf92c3a968c3f8b7 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 17 Mar 2024 16:21:35 +1000 Subject: [PATCH 05/52] add messages extracted from js --- warehouse/locale/messages.pot | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 9de5f48a558e..0c00a8558799 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -709,6 +709,22 @@ msgstr "" msgid "Your report has been recorded. Thank you for your help." msgstr "" +#: warehouse/static/js/warehouse/utils/timeago.js:33 +msgid "Yesterday" +msgstr "" + +#: warehouse/static/js/warehouse/utils/timeago.js:37 +msgid "an hour" +msgstr "" + +#: warehouse/static/js/warehouse/utils/timeago.js:39 +msgid "a minute" +msgstr "" + +#: warehouse/static/js/warehouse/utils/timeago.js:41 +msgid "Just now" +msgstr "" + #: warehouse/subscriptions/models.py:35 #: warehouse/templates/manage/project/history.html:230 msgid "Active" From 291189ba16d594ae59a3f5b99258b4b73bee6f14 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 17 Mar 2024 16:25:20 +1000 Subject: [PATCH 06/52] use the default pybabel js function name --- babel.cfg | 3 --- warehouse/static/js/warehouse/utils/timeago.js | 10 +++++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/babel.cfg b/babel.cfg index a8d480153cdc..cd1ac95c0a50 100644 --- a/babel.cfg +++ b/babel.cfg @@ -6,6 +6,3 @@ silent=False [javascript: **.js] encoding=utf-8 silent=False -parse_template_string=True -template_string=True -keywords=fetchGetText diff --git a/warehouse/static/js/warehouse/utils/timeago.js b/warehouse/static/js/warehouse/utils/timeago.js index d49a700c7523..259c2e3fd3df 100644 --- a/warehouse/static/js/warehouse/utils/timeago.js +++ b/warehouse/static/js/warehouse/utils/timeago.js @@ -11,7 +11,7 @@ * limitations under the License. */ -import fetchGetText from "warehouse/utils/fetch-gettext"; +import _ from "warehouse/utils/fetch-gettext"; const enumerateTime = (timestampString) => { const now = new Date(), @@ -30,15 +30,15 @@ const convertToReadableText = async (time) => { let { numDays, numMinutes, numHours } = time; if (numDays >= 1) { - return fetchGetText("Yesterday", "About ${numDays} days ago", numDays, {"numDays": numDays}); + return _("Yesterday", "About ${numDays} days ago", numDays, {"numDays": numDays}); } if (numHours > 0) { - return fetchGetText("an hour", "About ${numHours} ago", numHours, {"numHours": numHours}); + return _("an hour", "About ${numHours} ago", numHours, {"numHours": numHours}); } else if (numMinutes > 0) { - return fetchGetText("a minute", "About ${numMinutes} ago", numMinutes, {"numMinutes": numMinutes}); + return _("a minute", "About ${numMinutes} ago", numMinutes, {"numMinutes": numMinutes}); } else { - return fetchGetText("Just now"); + return _("Just now"); } }; From 64f4d9a19bd082090b7f5bc879ce350e23100260 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 17 Mar 2024 16:26:55 +1000 Subject: [PATCH 07/52] typo --- warehouse/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/views.py b/warehouse/views.py index e5cc1bd5eb04..bd15bbb56151 100644 --- a/warehouse/views.py +++ b/warehouse/views.py @@ -355,7 +355,7 @@ def search(request): metrics.increment("warehouse.views.search.error", tags=["error:query_too_long"]) raise HTTPRequestEntityTooLarge("Query string too long.") - ordr = request.params.get("o", "") + order = request.params.get("o", "") classifiers = request.params.getall("c") query = get_es_query(request.es, querystring, order, classifiers) From ec3b0bea35f190e4066022e3d9a6801ee7784098 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Tue, 19 Mar 2024 21:26:50 +1000 Subject: [PATCH 08/52] extract singular and plural translations from js --- warehouse/locale/messages.pot | 24 +++++++++------ .../js/warehouse/utils/fetch-gettext.js | 29 +++++++++++++++++++ .../static/js/warehouse/utils/timeago.js | 11 +++---- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 0c00a8558799..d23b3a5cdec9 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -709,19 +709,25 @@ msgstr "" msgid "Your report has been recorded. Thank you for your help." msgstr "" -#: warehouse/static/js/warehouse/utils/timeago.js:33 +#: warehouse/static/js/warehouse/utils/timeago.js:34 msgid "Yesterday" -msgstr "" +msgid_plural "About ${numDays} days ago" +msgstr[0] "" +msgstr[1] "" -#: warehouse/static/js/warehouse/utils/timeago.js:37 -msgid "an hour" -msgstr "" +#: warehouse/static/js/warehouse/utils/timeago.js:38 +msgid "About an hour ago" +msgid_plural "About ${numHours} hours ago" +msgstr[0] "" +msgstr[1] "" -#: warehouse/static/js/warehouse/utils/timeago.js:39 -msgid "a minute" -msgstr "" +#: warehouse/static/js/warehouse/utils/timeago.js:40 +msgid "About a minute ago" +msgid_plural "About ${numMinutes} minutes ago" +msgstr[0] "" +msgstr[1] "" -#: warehouse/static/js/warehouse/utils/timeago.js:41 +#: warehouse/static/js/warehouse/utils/timeago.js:42 msgid "Just now" msgstr "" diff --git a/warehouse/static/js/warehouse/utils/fetch-gettext.js b/warehouse/static/js/warehouse/utils/fetch-gettext.js index 022384724e6c..9903d8994d18 100644 --- a/warehouse/static/js/warehouse/utils/fetch-gettext.js +++ b/warehouse/static/js/warehouse/utils/fetch-gettext.js @@ -18,6 +18,35 @@ const fetchOptions = { redirect: "follow", }; +/** + * Get the translation using num to choose the appropriate string. + * + * When importing this function, it must be named in a particular way to be recognised by babel + * and have the translation strings extracted correctly. + * + * This approach uses the server-side localizer to process the translation strings. + * + * Any placeholders must be specified as '${placeholderName}' surrounded by single or double quote, + * not backticks (template literal). + * + * @example + * // Name the function 'gettext' for singular only extraction. + * import gettext from "warehouse/utils/fetch-gettext"; + * // Name the function ngettext for singular and plural extraction. + * import ngettext from "warehouse/utils/fetch-gettext"; + * // For a singular only string: + * gettext("Just now"); + * // For a singular and plural and placeholder string: + * ngettext("About a minute ago", "About ${numMinutes} minutes ago", numMinutes, {"numMinutes": numMinutes}); + + * @param singular {string} The default string for the singular translation. + * @param plural {string} The default string for the plural translation. + * @param num {number} The number to use to select the appropriate translation. + * @param values {object} Key value pairs to fill the placeholders. + * @returns {Promise} The Fetch API promise. + * @see https://www.gnu.org/software/gettext/manual/gettext.html#Language-specific-options + * @see https://docs.pylonsproject.org/projects/pyramid/en/latest/api/i18n.html#pyramid.i18n.Localizer.pluralize + */ export default (singular, plural, num, values) => { const partialMsg = `for singular '${singular}', plural '${plural}', num '${num}', values '${JSON.stringify(values || {})}'`; let searchValues = {s: singular}; diff --git a/warehouse/static/js/warehouse/utils/timeago.js b/warehouse/static/js/warehouse/utils/timeago.js index 259c2e3fd3df..1af6ec2faf9c 100644 --- a/warehouse/static/js/warehouse/utils/timeago.js +++ b/warehouse/static/js/warehouse/utils/timeago.js @@ -11,7 +11,8 @@ * limitations under the License. */ -import _ from "warehouse/utils/fetch-gettext"; +import gettext from "warehouse/utils/fetch-gettext"; +import ngettext from "warehouse/utils/fetch-gettext"; const enumerateTime = (timestampString) => { const now = new Date(), @@ -30,15 +31,15 @@ const convertToReadableText = async (time) => { let { numDays, numMinutes, numHours } = time; if (numDays >= 1) { - return _("Yesterday", "About ${numDays} days ago", numDays, {"numDays": numDays}); + return ngettext("Yesterday", "About ${numDays} days ago", numDays, {"numDays": numDays}); } if (numHours > 0) { - return _("an hour", "About ${numHours} ago", numHours, {"numHours": numHours}); + return ngettext("About an hour ago", "About ${numHours} hours ago", numHours, {"numHours": numHours}); } else if (numMinutes > 0) { - return _("a minute", "About ${numMinutes} ago", numMinutes, {"numMinutes": numMinutes}); + return ngettext("About a minute ago", "About ${numMinutes} minutes ago", numMinutes, {"numMinutes": numMinutes}); } else { - return _("Just now"); + return gettext("Just now"); } }; From f7057886d7878ffbaae0a97793c39be497ba936c Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Tue, 19 Mar 2024 22:07:14 +1000 Subject: [PATCH 09/52] fix lint issues and remove logging --- warehouse/views.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/warehouse/views.py b/warehouse/views.py index bd15bbb56151..2404878b00aa 100644 --- a/warehouse/views.py +++ b/warehouse/views.py @@ -302,7 +302,8 @@ def locale(request): renderer="json", request_method="GET", accept="application/json", - has_translations=True) + has_translations=True, +) def translation(request): singular = request.params.get("s", None) plural = request.params.get("p", None) @@ -311,20 +312,16 @@ def translation(request): values = request.params.mixed() if not singular: - request.log.info(f"translation params '${request.params}'") raise HTTPBadRequest("Message singular must be provided as 's'.") localizer = request.localizer - localizer_kwargs = {'domain': domain, 'mapping': values} + localizer_kwargs = {"domain": domain, "mapping": values} if plural is None or num is None: msg = localizer.translate(singular, **localizer_kwargs) else: msg = localizer.pluralize(singular, plural, int(num), **localizer_kwargs) - request.log.info(f"translation params '${request.params}'; msg '${msg}'; " - f"localizer locale '${localizer.locale_name}'.") - return {'msg': msg} - + return {"msg": msg} @view_config( From 4e9573967b2714b06e640098bd8e67ea86577495 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 20 Mar 2024 21:53:11 +1000 Subject: [PATCH 10/52] add translation to more js strings Provide two functions for gettext so IDe highlighting is useful. --- .../controllers/clipboard_controller.js | 5 ++- .../controllers/password_breach_controller.js | 6 ++- .../controllers/password_match_controller.js | 9 +++- .../password_strength_gauge_controller.js | 16 ++++++- .../js/warehouse/utils/fetch-gettext.js | 44 +++++++++++++------ .../static/js/warehouse/utils/timeago.js | 5 +-- 6 files changed, 61 insertions(+), 24 deletions(-) diff --git a/warehouse/static/js/warehouse/controllers/clipboard_controller.js b/warehouse/static/js/warehouse/controllers/clipboard_controller.js index 39fd69dc521e..4f754da60c10 100644 --- a/warehouse/static/js/warehouse/controllers/clipboard_controller.js +++ b/warehouse/static/js/warehouse/controllers/clipboard_controller.js @@ -12,6 +12,7 @@ * limitations under the License. */ import { Controller } from "@hotwired/stimulus"; +import { gettext } from "../utils/fetch-gettext"; // Copy handler for copy tooltips, e.g. // - the pip command on package detail page @@ -28,7 +29,9 @@ export default class extends Controller { // copy the source text to clipboard navigator.clipboard.writeText(this.sourceTarget.textContent); // set the tooltip text to "Copied" - this.tooltipTarget.dataset.clipboardTooltipValue = "Copied"; + gettext("Copied").then((text) => { + this.tooltipTarget.dataset.clipboardTooltipValue = text; + }); // on focusout and mouseout, reset the tooltip text to the original value const resetTooltip = () => { diff --git a/warehouse/static/js/warehouse/controllers/password_breach_controller.js b/warehouse/static/js/warehouse/controllers/password_breach_controller.js index 83f2f8a9860a..360f3669f0a8 100644 --- a/warehouse/static/js/warehouse/controllers/password_breach_controller.js +++ b/warehouse/static/js/warehouse/controllers/password_breach_controller.js @@ -14,6 +14,7 @@ import { Controller } from "@hotwired/stimulus"; import { debounce } from "debounce"; +import { gettext } from "../utils/fetch-gettext"; export default class extends Controller { static targets = ["password", "message"]; @@ -44,8 +45,9 @@ export default class extends Controller { let hex = this.hexString(digest); let response = await fetch(this.getURL(hex)); if (response.ok === false) { - const msg = "Error while validating hashed password, disregard on development"; - console.error(`${msg}: ${response.status} ${response.statusText}`); // eslint-disable-line no-console + gettext("Error while validating hashed password, disregard on development").then((text) => { + console.error(`${text}: ${response.status} ${response.statusText}`); // eslint-disable-line no-console + }); } else { let text = await response.text(); this.parseResponse(text, hex); diff --git a/warehouse/static/js/warehouse/controllers/password_match_controller.js b/warehouse/static/js/warehouse/controllers/password_match_controller.js index 076f304d09fe..2b525436e35a 100644 --- a/warehouse/static/js/warehouse/controllers/password_match_controller.js +++ b/warehouse/static/js/warehouse/controllers/password_match_controller.js @@ -13,6 +13,7 @@ */ import { Controller } from "@hotwired/stimulus"; +import { gettext } from "../utils/fetch-gettext"; export default class extends Controller { static targets = ["passwordMatch", "matchMessage", "submit"]; @@ -28,11 +29,15 @@ export default class extends Controller { } else { this.matchMessageTarget.classList.remove("hidden"); if (this.passwordMatchTargets.every((field, i, arr) => field.value === arr[0].value)) { - this.matchMessageTarget.textContent = "Passwords match"; + gettext("Passwords match").then((text) => { + this.matchMessageTarget.textContent = text; + }); this.matchMessageTarget.classList.add("form-error--valid"); this.submitTarget.removeAttribute("disabled"); } else { - this.matchMessageTarget.textContent = "Passwords do not match"; + gettext("Passwords do not match").then((text) => { + this.matchMessageTarget.textContent = text; + }); this.matchMessageTarget.classList.remove("form-error--valid"); this.submitTarget.setAttribute("disabled", ""); } diff --git a/warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js b/warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js index 9ef690d149ae..e58560d379ba 100644 --- a/warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js +++ b/warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js @@ -15,6 +15,7 @@ /* global zxcvbn */ import { Controller } from "@hotwired/stimulus"; +import { gettext } from "../utils/fetch-gettext"; export default class extends Controller { static targets = ["password", "strengthGauge"]; @@ -23,7 +24,9 @@ export default class extends Controller { let password = this.passwordTarget.value; if (password === "") { this.strengthGaugeTarget.setAttribute("class", "password-strength__gauge"); - this.setScreenReaderMessage("Password field is empty"); + gettext("Password field is empty").then((text) => { + this.setScreenReaderMessage(text); + }); } else { // following recommendations on the zxcvbn JS docs // the zxcvbn function is available by loading `vendor/zxcvbn.js` @@ -31,7 +34,16 @@ export default class extends Controller { let zxcvbnResult = zxcvbn(password.substring(0, 100)); this.strengthGaugeTarget.setAttribute("class", `password-strength__gauge password-strength__gauge--${zxcvbnResult.score}`); this.strengthGaugeTarget.setAttribute("data-zxcvbn-score", zxcvbnResult.score); - this.setScreenReaderMessage(zxcvbnResult.feedback.suggestions.join(" ") || "Password is strong"); + + // TODO: how to translate zxcvbn feedback suggestion strings? + const feedbackSuggestions = zxcvbnResult.feedback.suggestions.join(" "); + if (feedbackSuggestions) { + this.setScreenReaderMessage(feedbackSuggestions); + } else { + gettext("Password is strong").then((text) => { + this.setScreenReaderMessage(text); + }); + } } } diff --git a/warehouse/static/js/warehouse/utils/fetch-gettext.js b/warehouse/static/js/warehouse/utils/fetch-gettext.js index 9903d8994d18..e453542084c6 100644 --- a/warehouse/static/js/warehouse/utils/fetch-gettext.js +++ b/warehouse/static/js/warehouse/utils/fetch-gettext.js @@ -23,6 +23,7 @@ const fetchOptions = { * * When importing this function, it must be named in a particular way to be recognised by babel * and have the translation strings extracted correctly. + * Function 'gettext' for singular only extraction, function ngettext for singular and plural extraction. * * This approach uses the server-side localizer to process the translation strings. * @@ -30,10 +31,7 @@ const fetchOptions = { * not backticks (template literal). * * @example - * // Name the function 'gettext' for singular only extraction. - * import gettext from "warehouse/utils/fetch-gettext"; - * // Name the function ngettext for singular and plural extraction. - * import ngettext from "warehouse/utils/fetch-gettext"; + * import { gettext, ngettext } from "warehouse/utils/fetch-gettext"; * // For a singular only string: * gettext("Just now"); * // For a singular and plural and placeholder string: @@ -47,8 +45,7 @@ const fetchOptions = { * @see https://www.gnu.org/software/gettext/manual/gettext.html#Language-specific-options * @see https://docs.pylonsproject.org/projects/pyramid/en/latest/api/i18n.html#pyramid.i18n.Localizer.pluralize */ -export default (singular, plural, num, values) => { - const partialMsg = `for singular '${singular}', plural '${plural}', num '${num}', values '${JSON.stringify(values || {})}'`; +export function ngettext(singular, plural, num, values) { let searchValues = {s: singular}; if (plural) { searchValues.p = plural; @@ -63,15 +60,34 @@ export default (singular, plural, num, values) => { return fetch("/translation?" + searchParams.toString(), fetchOptions) .then(response => { if (response.ok) { - const responseJson = response.json(); - console.debug(`Fetch gettext success ${partialMsg}: ${responseJson}.`); - return responseJson; + return response.json(); } else { - console.warn(`Fetch gettext unexpected response ${partialMsg}: ${response.status}.`); - return ""; + throw new Error(`Unexpected response ${response.status}: ${response.body}.`); } - }).catch((err) => { - console.error(`Fetch gettext failed ${partialMsg}: ${err.message}.`); + }) + .then((json) => { + return json.msg; + }) + .catch(() => { return ""; }); -}; +} + +/** + * Get the singlar translation. + * + * When importing this function, it must be named in a particular way to be recognised by babel + * and have the translation strings extracted correctly. + * Function 'gettext' for singular only extraction, function ngettext for singular and plural extraction. + * + * This approach uses the server-side localizer to process the translation strings. + * + * Any placeholders must be specified as '${placeholderName}' surrounded by single or double quote, + * not backticks (template literal). + * + * @param singular {string} The default string for the singular translation. + * @returns {Promise} The Fetch API promise. + */ +export function gettext(singular) { + return ngettext(singular, undefined, undefined, undefined); +} diff --git a/warehouse/static/js/warehouse/utils/timeago.js b/warehouse/static/js/warehouse/utils/timeago.js index 1af6ec2faf9c..ab5cf9f68a1f 100644 --- a/warehouse/static/js/warehouse/utils/timeago.js +++ b/warehouse/static/js/warehouse/utils/timeago.js @@ -11,8 +11,7 @@ * limitations under the License. */ -import gettext from "warehouse/utils/fetch-gettext"; -import ngettext from "warehouse/utils/fetch-gettext"; +import { gettext, ngettext } from "./fetch-gettext"; const enumerateTime = (timestampString) => { const now = new Date(), @@ -51,7 +50,7 @@ export default () => { if (time.isBeforeCutoff) { convertToReadableText(time) .then((text) => { - timeElement.innerText = text.msg; + timeElement.innerText = text; }); } } From 266c1e5a77b959d7163d73beaecfb1953fcd9007 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 20 Mar 2024 21:53:55 +1000 Subject: [PATCH 11/52] add translation to more js strings --- warehouse/admin/static/js/warehouse.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/warehouse/admin/static/js/warehouse.js b/warehouse/admin/static/js/warehouse.js index 090a694bb313..e7a2b4c3f142 100644 --- a/warehouse/admin/static/js/warehouse.js +++ b/warehouse/admin/static/js/warehouse.js @@ -26,6 +26,7 @@ import "admin-lte/plugins/datatables-buttons/js/buttons.colVis"; // Import AdminLTE JS import "admin-lte/build/js/AdminLTE"; +import { gettext } from "warehouse/utils/fetch-gettext"; document.querySelectorAll("a[data-form-submit]").forEach(function (element) { @@ -100,7 +101,11 @@ document.querySelectorAll(".copy-text").forEach(function (element) { setTimeout(function () { $("#copied_tip").remove(); }, 1000); - $(target).append("
Copied!
"); + + gettext("Copied").then((text) => { + $(target).append(`
${text}
`); + }); + navigator.clipboard.writeText(text); } From 8ff40e48ef528601aed0d894446cec464ef27d8f Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 20 Mar 2024 21:54:08 +1000 Subject: [PATCH 12/52] add translation to more python strings --- warehouse/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/warehouse/forms.py b/warehouse/forms.py index 8b36226d88df..5c12a5785739 100644 --- a/warehouse/forms.py +++ b/warehouse/forms.py @@ -14,6 +14,7 @@ from wtforms.validators import InputRequired, StopValidation, ValidationError from zxcvbn import zxcvbn +from warehouse.i18n import localize as _ from warehouse.i18n import KNOWN_LOCALES from warehouse.utils.http import is_valid_uri @@ -68,7 +69,7 @@ def __call__(self, form, field): msg = ( results["feedback"]["warning"] if results["feedback"]["warning"] - else "Password is too easily guessed." + else _("Password is too easily guessed.") ) if results["feedback"]["suggestions"]: msg += " " + " ".join(results["feedback"]["suggestions"]) From 7dc6ea49e0c22821e191b7ab5cd9d9a291930db3 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 20 Mar 2024 21:54:25 +1000 Subject: [PATCH 13/52] update and add tests for js translations --- tests/frontend/clipboard_controller_test.js | 11 +- .../password_match_controller_test.js | 39 +++++- ...password_strength_gauge_controller_test.js | 35 ++++- tests/frontend/timeago_test.js | 129 ++++++++++++++++++ 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 tests/frontend/timeago_test.js diff --git a/tests/frontend/clipboard_controller_test.js b/tests/frontend/clipboard_controller_test.js index 59565da9cce5..8528cbde250c 100644 --- a/tests/frontend/clipboard_controller_test.js +++ b/tests/frontend/clipboard_controller_test.js @@ -15,6 +15,7 @@ import { Application } from "@hotwired/stimulus"; import ClipboardController from "../../warehouse/static/js/warehouse/controllers/clipboard_controller"; +import {delay} from "./utils"; // Create a mock clipboard object, since jsdom doesn't support the clipboard API // See https://github.com/jsdom/jsdom/issues/1568 @@ -46,10 +47,14 @@ describe("ClipboardController", () => { document.body.innerHTML = clipboardContent; const application = Application.start(); application.register("clipboard", ClipboardController); + + fetch.resetMocks(); }); describe("copy", () => { - it("copies text to clipboard and resets", () => { + it("copies text to clipboard and resets", async () => { + fetch.mockResponseOnce(JSON.stringify({"msg": "Copied"})); + const button = document.querySelector(".copy-tooltip"); expect(button.dataset.clipboardTooltipValue).toEqual("Copy to clipboard"); @@ -58,12 +63,16 @@ describe("ClipboardController", () => { // Check that the text was copied to the clipboard expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Copyable Thing"); // Check that the tooltip text was changed + await delay(25); expect(button.dataset.clipboardTooltipValue).toEqual("Copied"); button.dispatchEvent(new FocusEvent("focusout")); // Check that the tooltip text was reset expect(button.dataset.clipboardTooltipValue).toEqual("Copy to clipboard"); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Copied"); }); }); }); diff --git a/tests/frontend/password_match_controller_test.js b/tests/frontend/password_match_controller_test.js index 322f9ebe9637..235c8426e6f6 100644 --- a/tests/frontend/password_match_controller_test.js +++ b/tests/frontend/password_match_controller_test.js @@ -16,6 +16,7 @@ import { getByPlaceholderText, fireEvent } from "@testing-library/dom"; import { Application } from "@hotwired/stimulus"; import PasswordMatchController from "../../warehouse/static/js/warehouse/controllers/password_match_controller"; +import { delay } from "./utils"; describe("Password match controller", () => { beforeEach(() => { @@ -30,6 +31,8 @@ describe("Password match controller", () => { const application = Application.start(); application.register("password-match", PasswordMatchController); + + fetch.resetMocks(); }); describe("initial state", function() { @@ -69,32 +72,64 @@ describe("Password match controller", () => { }); describe("adding different text on each field", function() { - it("shows text warning of mismatch and disables submit", function() { + it("shows text warning of mismatch and disables submit", async function() { + fetch.mockResponses( + [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], + [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], + [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], + [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], + ); + fireEvent.input(getByPlaceholderText(document.body, "Your password"), { target: { value: "foo" } }); fireEvent.input(getByPlaceholderText(document.body, "Confirm password"), { target: { value: "bar" } }); + await delay(25); + const message = document.getElementsByTagName("p")[0]; expect(message).toHaveTextContent("Passwords do not match"); expect(message).not.toHaveClass("hidden"); expect(message).not.toHaveClass("form-error--valid"); const submit = document.getElementsByTagName("input")[2]; expect(submit).toHaveAttribute("disabled", ""); + + expect(fetch.mock.calls.length).toEqual(4); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Passwords+do+not+match"); + expect(fetch.mock.calls[1][0]).toEqual("/translation?s=Passwords+do+not+match"); + expect(fetch.mock.calls[2][0]).toEqual("/translation?s=Passwords+do+not+match"); + expect(fetch.mock.calls[3][0]).toEqual("/translation?s=Passwords+do+not+match"); }); }); }); describe("correct inputs", function() { describe("adding the same text on each field", function() { - it("shows success text and enables submit", function() { + it("shows success text and enables submit", async function() { + fetch.mockResponses( + [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], + [JSON.stringify({"msg": "Passwords match"}),{ status: 200 }], + [JSON.stringify({"msg": "Passwords match"}),{ status: 200 }], + [JSON.stringify({"msg": "Passwords match"}),{ status: 200 }], + [JSON.stringify({"msg": "Passwords match"}),{ status: 200 }], + ); + fireEvent.input(getByPlaceholderText(document.body, "Your password"), { target: { value: "foo" } }); fireEvent.input(getByPlaceholderText(document.body, "Confirm password"), { target: { value: "foo" } }); + await delay(25); + const message = document.getElementsByTagName("p")[0]; expect(message).toHaveTextContent("Passwords match"); expect(message).not.toHaveClass("hidden"); expect(message).toHaveClass("form-error--valid"); const submit = document.getElementsByTagName("input")[2]; expect(submit).not.toHaveAttribute("disabled"); + + expect(fetch.mock.calls.length).toEqual(5); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Passwords+match"); + expect(fetch.mock.calls[1][0]).toEqual("/translation?s=Passwords+match"); + expect(fetch.mock.calls[2][0]).toEqual("/translation?s=Passwords+match"); + expect(fetch.mock.calls[3][0]).toEqual("/translation?s=Passwords+match"); + expect(fetch.mock.calls[4][0]).toEqual("/translation?s=Passwords+match"); }); }); diff --git a/tests/frontend/password_strength_gauge_controller_test.js b/tests/frontend/password_strength_gauge_controller_test.js index af077dd25ba7..fb4d80365b24 100644 --- a/tests/frontend/password_strength_gauge_controller_test.js +++ b/tests/frontend/password_strength_gauge_controller_test.js @@ -16,6 +16,7 @@ import { getByPlaceholderText, fireEvent } from "@testing-library/dom"; import { Application } from "@hotwired/stimulus"; import PasswordStrengthGaugeController from "../../warehouse/static/js/warehouse/controllers/password_strength_gauge_controller"; +import {delay} from "./utils"; describe("Password strength gauge controller", () => { beforeEach(() => { @@ -35,12 +36,21 @@ describe("Password strength gauge controller", () => { const application = Application.start(); application.register("password-strength-gauge", PasswordStrengthGaugeController); + + fetch.resetMocks(); }); describe("initial state", () => { describe("the password strength gauge and screen reader text", () => { - it("are at 0 level and reading a password empty text", () => { + it("are at 0 level and reading a password empty text", async () => { + fetch.mockResponses( + [JSON.stringify({"msg": "Password field is empty"}),{ status: 200 }], + ); + + const passwordTarget = getByPlaceholderText(document.body, "Your password"); + fireEvent.input(passwordTarget, { target: { value: "" } }); + const gauge = document.getElementById("gauge"); const ZXCVBN_LEVELS = [0, 1, 2, 3, 4]; ZXCVBN_LEVELS.forEach(i => @@ -48,6 +58,9 @@ describe("Password strength gauge controller", () => { ); expect(gauge).not.toHaveAttribute("data-zxcvbn-score"); expect(gauge.querySelector(".sr-only")).toHaveTextContent("Password field is empty"); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Password+field+is+empty"); }); }); }); @@ -63,6 +76,8 @@ describe("Password strength gauge controller", () => { }, }; }); + + const passwordTarget = getByPlaceholderText(document.body, "Your password"); fireEvent.input(passwordTarget, { target: { value: "foo" } }); @@ -74,8 +89,8 @@ describe("Password strength gauge controller", () => { }); describe("that are strong", () => { - it("show high score and suggestions on screen reader", () => { - window.zxcvbn = jest.fn(() => { + it("show high score and suggestions on screen reader", async () => { + window.zxcvbn = jest.fn( () => { return { score: 5, feedback: { @@ -83,13 +98,27 @@ describe("Password strength gauge controller", () => { }, }; }); + + fetch.mockResponses( + [JSON.stringify({"msg": "Password is strong"}),{ status: 200 }], + [JSON.stringify({"msg": "Password is strong"}),{ status: 200 }], + [JSON.stringify({"msg": "Password is strong"}),{ status: 200 }], + ); + const passwordTarget = getByPlaceholderText(document.body, "Your password"); fireEvent.input(passwordTarget, { target: { value: "the strongest password ever" } }); + await delay(25); + const gauge = document.getElementById("gauge"); expect(gauge).toHaveClass("password-strength__gauge--5"); expect(gauge).toHaveAttribute("data-zxcvbn-score", "5"); expect(gauge.querySelector(".sr-only")).toHaveTextContent("Password is strong"); + + expect(fetch.mock.calls.length).toEqual(3); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Password+is+strong"); + expect(fetch.mock.calls[1][0]).toEqual("/translation?s=Password+is+strong"); + expect(fetch.mock.calls[2][0]).toEqual("/translation?s=Password+is+strong"); }); }); }); diff --git a/tests/frontend/timeago_test.js b/tests/frontend/timeago_test.js new file mode 100644 index 000000000000..7d43df4d10f2 --- /dev/null +++ b/tests/frontend/timeago_test.js @@ -0,0 +1,129 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* global expect, describe, beforeEach, it */ + +import timeAgo from "../../warehouse/static/js/warehouse/utils/timeago"; +import {delay} from "./utils"; + +describe("time ago util", () => { + beforeEach(() => { + document.documentElement.lang = "en"; + + fetch.resetMocks(); + }); + + it("shows 'just now' for a very recent time'", async () => { + document.body.innerHTML = ` + + `; + + fetch.mockResponses( + [JSON.stringify({"msg": "Just now"}), {status: 200}], + ); + + timeAgo(); + + await delay(25); + + const element = document.getElementById("element"); + expect(element.innerText).toEqual("Just now"); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Just+now"); + }); + + it("shows 'About 5 hours ago' for such a time'", async () => { + const date = new Date(); + date.setHours(date.getHours() - 5); + + document.body.innerHTML = ` + + `; + + fetch.mockResponses( + [JSON.stringify({"msg": "About 5 hours ago"}), {status: 200}], + ); + + timeAgo(); + + await delay(25); + + const element = document.getElementById("element"); + expect(element.innerText).toEqual("About 5 hours ago"); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=About+an+hour+ago&p=About+%24%7BnumHours%7D+hours+ago&n=5&numHours=5"); + }); + + it("shows 'About 36 minutes ago' for such a time'", async () => { + const date = new Date(); + date.setMinutes(date.getMinutes() - 36); + + document.body.innerHTML = ` + + `; + + fetch.mockResponses( + [JSON.stringify({"msg": "About 36 minutes ago"}), {status: 200}], + ); + + timeAgo(); + + await delay(25); + + const element = document.getElementById("element"); + expect(element.innerText).toEqual("About 36 minutes ago"); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=About+a+minute+ago&p=About+%24%7BnumMinutes%7D+minutes+ago&n=36&numMinutes=36"); + }); + + it("shows provided text for Yesterday'", async () => { + const date = new Date(); + date.setHours(date.getHours() - 24); + + document.body.innerHTML = ` + + `; + + fetch.mockResponses( + [JSON.stringify({"msg": "one day ago"}), {status: 200}], + ); + + timeAgo(); + + await delay(25); + + const element = document.getElementById("element"); + expect(element.innerText).toEqual("one day ago"); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Yesterday&p=About+%24%7BnumDays%7D+days+ago&n=1&numDays=1"); + }); + + it("makes no fetch call when not isBeforeCutoff", async () => { + document.body.innerHTML = ` + + `; + + timeAgo(); + + await delay(25); + + const element = document.getElementById("element"); + expect(element.textContent).toEqual("existing text"); + + expect(fetch.mock.calls.length).toEqual(0); + }); +}); From c593101f74aae91d95fc51f67ba2eee5f91ffe6c Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 20 Mar 2024 21:55:02 +1000 Subject: [PATCH 14/52] update translations --- warehouse/locale/messages.pot | 42 ++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index d23b3a5cdec9..8be5bdbf8fb3 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -1,3 +1,7 @@ +#: warehouse/forms.py:72 +msgid "Password is too easily guessed." +msgstr "" + #: warehouse/views.py:142 msgid "" "You must verify your **primary** email address before you can perform " @@ -336,6 +340,11 @@ msgstr "" msgid "Removed trusted publisher for project " msgstr "" +#: warehouse/admin/static/js/warehouse.js:105 +#: warehouse/static/js/warehouse/controllers/clipboard_controller.js:32 +msgid "Copied" +msgstr "" + #: warehouse/admin/templates/admin/banners/preview.html:15 msgid "Banner Preview" msgstr "" @@ -709,25 +718,46 @@ msgstr "" msgid "Your report has been recorded. Thank you for your help." msgstr "" -#: warehouse/static/js/warehouse/utils/timeago.js:34 +#: warehouse/static/js/warehouse/controllers/password_breach_controller.js:48 +msgid "Error while validating hashed password, disregard on development" +msgstr "" + +#: warehouse/static/js/warehouse/controllers/password_match_controller.js:32 +msgid "Passwords match" +msgstr "" + +#: warehouse/static/js/warehouse/controllers/password_match_controller.js:38 +msgid "Passwords do not match" +msgstr "" + +#: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:27 +#: warehouse/templates/base.html:30 +msgid "Password field is empty" +msgstr "" + +#: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:43 +msgid "Password is strong" +msgstr "" + +#: warehouse/static/js/warehouse/utils/timeago.js:33 msgid "Yesterday" msgid_plural "About ${numDays} days ago" msgstr[0] "" msgstr[1] "" -#: warehouse/static/js/warehouse/utils/timeago.js:38 +#: warehouse/static/js/warehouse/utils/timeago.js:37 msgid "About an hour ago" msgid_plural "About ${numHours} hours ago" msgstr[0] "" msgstr[1] "" -#: warehouse/static/js/warehouse/utils/timeago.js:40 +#: warehouse/static/js/warehouse/utils/timeago.js:39 msgid "About a minute ago" msgid_plural "About ${numMinutes} minutes ago" msgstr[0] "" msgstr[1] "" -#: warehouse/static/js/warehouse/utils/timeago.js:42 +#: warehouse/static/js/warehouse/utils/timeago.js:41 msgid "Just now" msgstr "" @@ -948,10 +978,6 @@ msgstr "" msgid "Password strength:" msgstr "" -#: warehouse/templates/base.html:30 -msgid "Password field is empty" -msgstr "" - #: warehouse/templates/base.html:39 warehouse/templates/base.html:47 #: warehouse/templates/includes/current-user-indicator.html:17 msgid "Main navigation" From c1a7811008e03698088137638c17ab2ac8f0369f Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 24 Mar 2024 19:53:43 +1000 Subject: [PATCH 15/52] fix route test --- tests/unit/test_routes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index b8b509ebc577..94fc7dbbca3c 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -77,6 +77,7 @@ def add_policy(name, filename): pretend.call("force-status", r"/_force-status/{status:[45]\d\d}/"), pretend.call("index", "/", domain=warehouse), pretend.call("locale", "/locale/", domain=warehouse), + pretend.call("translation", "/translation/", domain=warehouse), pretend.call("robots.txt", "/robots.txt", domain=warehouse), pretend.call("opensearch.xml", "/opensearch.xml", domain=warehouse), pretend.call("index.sitemap.xml", "/sitemap.xml", domain=warehouse), From 25f2c288d21be69803ffe644daa4c64c370a1b54 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 24 Mar 2024 19:54:08 +1000 Subject: [PATCH 16/52] avoid redirect --- warehouse/static/js/warehouse/utils/fetch-gettext.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/static/js/warehouse/utils/fetch-gettext.js b/warehouse/static/js/warehouse/utils/fetch-gettext.js index e453542084c6..f307c54aa47a 100644 --- a/warehouse/static/js/warehouse/utils/fetch-gettext.js +++ b/warehouse/static/js/warehouse/utils/fetch-gettext.js @@ -57,7 +57,7 @@ export function ngettext(singular, plural, num, values) { searchValues = {...searchValues, ...values}; } const searchParams = new URLSearchParams(searchValues); - return fetch("/translation?" + searchParams.toString(), fetchOptions) + return fetch("/translation/?" + searchParams.toString(), fetchOptions) .then(response => { if (response.ok) { return response.json(); From 1a6b4e0dfadaba5a9547bb101e18267dbc4d6d85 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 24 Mar 2024 19:54:17 +1000 Subject: [PATCH 17/52] fix lint error --- warehouse/forms.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/warehouse/forms.py b/warehouse/forms.py index 5c12a5785739..64497543c14f 100644 --- a/warehouse/forms.py +++ b/warehouse/forms.py @@ -14,8 +14,7 @@ from wtforms.validators import InputRequired, StopValidation, ValidationError from zxcvbn import zxcvbn -from warehouse.i18n import localize as _ -from warehouse.i18n import KNOWN_LOCALES +from warehouse.i18n import KNOWN_LOCALES, localize as _ from warehouse.utils.http import is_valid_uri From d73ee323830fe00474f4ac8f6653b13e3f7c664b Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 24 Mar 2024 19:56:49 +1000 Subject: [PATCH 18/52] update translation url in tests --- tests/frontend/clipboard_controller_test.js | 2 +- .../frontend/password_match_controller_test.js | 18 +++++++++--------- .../password_strength_gauge_controller_test.js | 8 ++++---- tests/frontend/timeago_test.js | 8 ++++---- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/frontend/clipboard_controller_test.js b/tests/frontend/clipboard_controller_test.js index 8528cbde250c..10f866e7012a 100644 --- a/tests/frontend/clipboard_controller_test.js +++ b/tests/frontend/clipboard_controller_test.js @@ -72,7 +72,7 @@ describe("ClipboardController", () => { expect(button.dataset.clipboardTooltipValue).toEqual("Copy to clipboard"); expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Copied"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Copied"); }); }); }); diff --git a/tests/frontend/password_match_controller_test.js b/tests/frontend/password_match_controller_test.js index 235c8426e6f6..5675ffec8492 100644 --- a/tests/frontend/password_match_controller_test.js +++ b/tests/frontend/password_match_controller_test.js @@ -93,10 +93,10 @@ describe("Password match controller", () => { expect(submit).toHaveAttribute("disabled", ""); expect(fetch.mock.calls.length).toEqual(4); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Passwords+do+not+match"); - expect(fetch.mock.calls[1][0]).toEqual("/translation?s=Passwords+do+not+match"); - expect(fetch.mock.calls[2][0]).toEqual("/translation?s=Passwords+do+not+match"); - expect(fetch.mock.calls[3][0]).toEqual("/translation?s=Passwords+do+not+match"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Passwords+do+not+match"); + expect(fetch.mock.calls[1][0]).toEqual("/translation/?s=Passwords+do+not+match"); + expect(fetch.mock.calls[2][0]).toEqual("/translation/?s=Passwords+do+not+match"); + expect(fetch.mock.calls[3][0]).toEqual("/translation/?s=Passwords+do+not+match"); }); }); }); @@ -125,11 +125,11 @@ describe("Password match controller", () => { expect(submit).not.toHaveAttribute("disabled"); expect(fetch.mock.calls.length).toEqual(5); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Passwords+match"); - expect(fetch.mock.calls[1][0]).toEqual("/translation?s=Passwords+match"); - expect(fetch.mock.calls[2][0]).toEqual("/translation?s=Passwords+match"); - expect(fetch.mock.calls[3][0]).toEqual("/translation?s=Passwords+match"); - expect(fetch.mock.calls[4][0]).toEqual("/translation?s=Passwords+match"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Passwords+match"); + expect(fetch.mock.calls[1][0]).toEqual("/translation/?s=Passwords+match"); + expect(fetch.mock.calls[2][0]).toEqual("/translation/?s=Passwords+match"); + expect(fetch.mock.calls[3][0]).toEqual("/translation/?s=Passwords+match"); + expect(fetch.mock.calls[4][0]).toEqual("/translation/?s=Passwords+match"); }); }); diff --git a/tests/frontend/password_strength_gauge_controller_test.js b/tests/frontend/password_strength_gauge_controller_test.js index fb4d80365b24..99f81b90552f 100644 --- a/tests/frontend/password_strength_gauge_controller_test.js +++ b/tests/frontend/password_strength_gauge_controller_test.js @@ -60,7 +60,7 @@ describe("Password strength gauge controller", () => { expect(gauge.querySelector(".sr-only")).toHaveTextContent("Password field is empty"); expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Password+field+is+empty"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Password+field+is+empty"); }); }); }); @@ -116,9 +116,9 @@ describe("Password strength gauge controller", () => { expect(gauge.querySelector(".sr-only")).toHaveTextContent("Password is strong"); expect(fetch.mock.calls.length).toEqual(3); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Password+is+strong"); - expect(fetch.mock.calls[1][0]).toEqual("/translation?s=Password+is+strong"); - expect(fetch.mock.calls[2][0]).toEqual("/translation?s=Password+is+strong"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Password+is+strong"); + expect(fetch.mock.calls[1][0]).toEqual("/translation/?s=Password+is+strong"); + expect(fetch.mock.calls[2][0]).toEqual("/translation/?s=Password+is+strong"); }); }); }); diff --git a/tests/frontend/timeago_test.js b/tests/frontend/timeago_test.js index 7d43df4d10f2..763b22320183 100644 --- a/tests/frontend/timeago_test.js +++ b/tests/frontend/timeago_test.js @@ -40,7 +40,7 @@ describe("time ago util", () => { expect(element.innerText).toEqual("Just now"); expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Just+now"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Just+now"); }); it("shows 'About 5 hours ago' for such a time'", async () => { @@ -63,7 +63,7 @@ describe("time ago util", () => { expect(element.innerText).toEqual("About 5 hours ago"); expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=About+an+hour+ago&p=About+%24%7BnumHours%7D+hours+ago&n=5&numHours=5"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=About+an+hour+ago&p=About+%24%7BnumHours%7D+hours+ago&n=5&numHours=5"); }); it("shows 'About 36 minutes ago' for such a time'", async () => { @@ -86,7 +86,7 @@ describe("time ago util", () => { expect(element.innerText).toEqual("About 36 minutes ago"); expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=About+a+minute+ago&p=About+%24%7BnumMinutes%7D+minutes+ago&n=36&numMinutes=36"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=About+a+minute+ago&p=About+%24%7BnumMinutes%7D+minutes+ago&n=36&numMinutes=36"); }); it("shows provided text for Yesterday'", async () => { @@ -109,7 +109,7 @@ describe("time ago util", () => { expect(element.innerText).toEqual("one day ago"); expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation?s=Yesterday&p=About+%24%7BnumDays%7D+days+ago&n=1&numDays=1"); + expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Yesterday&p=About+%24%7BnumDays%7D+days+ago&n=1&numDays=1"); }); it("makes no fetch call when not isBeforeCutoff", async () => { From b44655fbb4b842c90956334bc4916ca88242b777 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 24 Mar 2024 19:57:53 +1000 Subject: [PATCH 19/52] update translations --- warehouse/locale/messages.pot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 71660e7d58d0..6636c55f292b 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -1,4 +1,4 @@ -#: warehouse/forms.py:72 +#: warehouse/forms.py:71 msgid "Password is too easily guessed." msgstr "" From 9a431476a312cf3f1460b9d4459babb11e0950e5 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 31 Mar 2024 18:18:21 +1000 Subject: [PATCH 20/52] don't mix translated and untranslated text Co-authored-by: Dustin Ingram --- warehouse/forms.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/warehouse/forms.py b/warehouse/forms.py index 64497543c14f..23b55a6d56ec 100644 --- a/warehouse/forms.py +++ b/warehouse/forms.py @@ -68,7 +68,9 @@ def __call__(self, form, field): msg = ( results["feedback"]["warning"] if results["feedback"]["warning"] - else _("Password is too easily guessed.") + # Note: we can't localize this string because it will be mixed + # with other non-localizable strings from zxcvbn + else "Password is too easily guessed." ) if results["feedback"]["suggestions"]: msg += " " + " ".join(results["feedback"]["suggestions"]) From 18eb472dd34b4324c237cd910bcdeea5c2904bbc Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 31 Mar 2024 18:41:51 +1000 Subject: [PATCH 21/52] remove unused import --- warehouse/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/forms.py b/warehouse/forms.py index 23b55a6d56ec..0773ceb21443 100644 --- a/warehouse/forms.py +++ b/warehouse/forms.py @@ -14,7 +14,7 @@ from wtforms.validators import InputRequired, StopValidation, ValidationError from zxcvbn import zxcvbn -from warehouse.i18n import KNOWN_LOCALES, localize as _ +from warehouse.i18n import KNOWN_LOCALES from warehouse.utils.http import is_valid_uri From 7303e143100f8a09b89a53de4016b19ef2dfa3f2 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 31 Mar 2024 18:42:27 +1000 Subject: [PATCH 22/52] update translations --- warehouse/locale/messages.pot | 4 ---- 1 file changed, 4 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 50cf59d327d4..c0596a6934e5 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -1,7 +1,3 @@ -#: warehouse/forms.py:71 -msgid "Password is too easily guessed." -msgstr "" - #: warehouse/views.py:142 msgid "" "You must verify your **primary** email address before you can perform " From 42ed35d157ca10f21c76653139e2832172c405e2 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 31 Mar 2024 18:53:05 +1000 Subject: [PATCH 23/52] update translations --- warehouse/locale/messages.pot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index c0596a6934e5..66033850abb1 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -336,7 +336,7 @@ msgstr "" msgid "Removed trusted publisher for project " msgstr "" -#: warehouse/admin/static/js/warehouse.js:105 +#: warehouse/admin/static/js/warehouse.js:107 #: warehouse/static/js/warehouse/controllers/clipboard_controller.js:32 msgid "Copied" msgstr "" From 07341f309bd548c7c75b4e52a14faf10cb8d78df Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 31 Mar 2024 23:08:03 +1000 Subject: [PATCH 24/52] add tests for translation action --- tests/unit/test_views.py | 125 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/unit/test_views.py b/tests/unit/test_views.py index 93138e09e789..536caa9ae7ff 100644 --- a/tests/unit/test_views.py +++ b/tests/unit/test_views.py @@ -11,6 +11,7 @@ # limitations under the License. import datetime +import json import elasticsearch import pretend @@ -50,6 +51,7 @@ session_notifications, sidebar_sponsor_logo, stats, + translation, ) from ..common.db.accounts import UserFactory @@ -436,6 +438,129 @@ def test_locale_bad_request(self, get, monkeypatch): locale(request) +class TestTranslation: + @pytest.mark.parametrize( + ("referer", "redirect", "params"), + [ + (None, "/fake-route", MultiDict({"s": "Test text string"})), + ("/robots.txt", "/robots.txt", MultiDict({"s": "Test text string"})), + ], + ) + def test_translation_singular(self, referer, redirect, params): + def _translate(tstring, domain, mapping): + return json.dumps( + { + "tstring": tstring, + "domain": "messages", + "val": "test value", + } + ) + + localizer = pretend.stub(translate=_translate) + request = pretend.stub( + params=params, + referer=referer, + route_path=pretend.call_recorder(lambda r: "/fake-route"), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + host=None, + localizer=localizer, + ) + result = translation(request) + assert result == { + "msg": json.dumps( + { + "tstring": "Test text string", + "domain": "messages", + "val": "test value", + } + ) + } + + @pytest.mark.parametrize( + ("referer", "redirect", "params"), + [ + ( + None, + "/fake-route", + MultiDict( + { + "s": "Test text string ${va}", + "p": "Test text strings ${val}", + "n": 3, + "val": "test value", + } + ), + ), + ( + "/robots.txt", + "/robots.txt", + MultiDict( + { + "s": "Test text string ${va}", + "p": "Test text strings ${val}", + "n": 3, + "val": "test value", + } + ), + ), + ], + ) + def test_translation_plural(self, referer, redirect, params): + def _pluralize(singular, plural, n, domain, mapping): + return json.dumps( + { + "singular": singular, + "plural": plural, + "n": n, + "domain": "messages", + "val": "test value", + } + ) + + localizer = pretend.stub(pluralize=_pluralize) + request = pretend.stub( + params=params, + referer=referer, + route_path=pretend.call_recorder(lambda r: "/fake-route"), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + host=None, + localizer=localizer, + ) + result = translation(request) + assert result == { + "msg": json.dumps( + { + "singular": "Test text string ${va}", + "plural": "Test text strings ${val}", + "n": 3, + "domain": "messages", + "val": "test value", + } + ) + } + + @pytest.mark.parametrize( + "params", + [ + MultiDict({"nonsense": "arguments"}), + MultiDict({"n": "2"}), + MultiDict({"n": "2", "p": "plural strings ${val}", "val": "the value"}), + ], + ) + def test_translation_bad_request(self, params): + request = pretend.stub( + params=params, + route_path=pretend.call_recorder(lambda r: "/fake-route"), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + host=None, + ) + + with pytest.raises( + HTTPBadRequest, match="Message singular must be provided as 's'." + ): + translation(request) + + def test_csi_current_user_indicator(): assert current_user_indicator(pretend.stub()) == {} From 8fb15ddf5964ede68e1a6f6afee1a2c996c5bee4 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sat, 6 Apr 2024 14:37:34 +1000 Subject: [PATCH 25/52] draft of alternate attempt that generates js-only translations and uses gettext.js --- Makefile | 3 +- bin/translations-js | 109 ++++++++++++++++++ package-lock.json | 80 ++++++++++++- package.json | 1 + warehouse/locale/am/LC_MESSAGES/messages.po | 8 +- warehouse/locale/messages.pot | 6 +- .../{fetch-gettext.js => messages-access.js} | 73 +++++------- .../static/js/warehouse/utils/messages.json | 10 ++ .../static/js/warehouse/utils/timeago.js | 8 +- 9 files changed, 242 insertions(+), 56 deletions(-) create mode 100644 bin/translations-js rename warehouse/static/js/warehouse/utils/{fetch-gettext.js => messages-access.js} (51%) create mode 100644 warehouse/static/js/warehouse/utils/messages.json diff --git a/Makefile b/Makefile index e85d88322ba4..1329a44ee1f1 100644 --- a/Makefile +++ b/Makefile @@ -93,8 +93,9 @@ licenses: .state/docker-build-base deps: .state/docker-build-base docker compose run --rm base bin/deps -translations: .state/docker-build-base +translations: .state/docker-build-base .state/docker-build-static docker compose run --rm base bin/translations + docker compose run --rm static node bin/translations-js warehouse/locale requirements/%.txt: requirements/%.in docker compose run --rm base bin/pip-compile --generate-hashes --output-file=$@ $< diff --git a/bin/translations-js b/bin/translations-js new file mode 100644 index 000000000000..475b805e5940 --- /dev/null +++ b/bin/translations-js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +/* + po2json wrapper for gettext.js + https://github.com/mikeedwards/po2json + + based on https://github.com/guillaumepotier/gettext.js/blob/v2.0.2/bin/po2json + + Dump all .po files in one json file containing an array with entries like this one: + + { + "": { + "language": "en", + "plural-forms": "nplurals=2; plural=(n!=1);" + }, + "simple key": "It's tranlation", + "another with %1 parameter": "It's %1 tranlsation", + "a key with plural": [ + "a plural form", + "another plural form", + "could have up to 6 forms with some languages" + ], + "a context\u0004a contextualized key": "translation here" + } + +*/ + +const {readFile, writeFile, readdir, stat} = require("node:fs/promises"); +const {resolve} = require("node:path"); +const gettextParser = require("gettext-parser"); + +const argv = process.argv; + +const runPo2Json = async function (filePath) { + const buffer = await readFile(filePath); + const jsonData = gettextParser.po.parse(buffer, "utf-8"); + + // Build the format expected by gettext.js. + // Includes only translations from .js files. + // NOTE: This assumes there is only one msgctxt (the default ""), + // and that default msgid ("") contains the headers. + const translations = jsonData.translations[""]; + const jsonResult = { + "": { + "language": jsonData["headers"]["language"], + "plural-forms": jsonData["headers"]["plural-forms"], + } + }; + + for (const msgid in translations) { + if ("" === msgid) { + continue; + } + + const item = translations[msgid]; + + if (!item["comments"]["reference"].includes(".js:")) { + // ignore non-js translations + continue; + } + + const values = item["msgstr"]; + if (!values.some((value) => value.length > 0 && value !== msgid)) { + // only include if there are any translated strings + continue; + } + + if (item["msgstr"].length === 1) { + jsonResult[msgid] = values[0]; + } else { + jsonResult[msgid] = values; + } + } + + return jsonResult; +} + + +const recurseDir = async function recurseDir(dir) { + const result = []; + const files = await readdir(dir); + for (const file of files) { + const filePath = resolve(dir, file); + const fileStat = await stat(filePath); + if (fileStat.isDirectory()) { + const results = await recurseDir(filePath); + result.push(...results); + } else if (filePath.endsWith("messages.po")) { + const item = await runPo2Json(filePath); + if (Object.keys(item).length > 1) { + // only include if there are translated strings + console.log(`found js messages in ${filePath}`); + result.push(await runPo2Json(filePath)); + } + } + } + return result; +}; + +const updateJsonTranslations = async function updateJsonTranslations(dir) { + const result = await recurseDir(dir); + const destFile = resolve("./warehouse/static/js/warehouse/utils/messages.json"); + const destData = JSON.stringify(result, null, 2) + await writeFile(destFile, destData); + console.log(`writing js messages to ${destFile}`); +} + + +updateJsonTranslations(argv[2]); diff --git a/package-lock.json b/package-lock.json index 04e4768502c2..923e1da53fdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "src", + "name": "warehouse", "lockfileVersion": 3, "requires": true, "packages": { @@ -13,6 +13,7 @@ "cookie": "^0.5.0", "date-fns": "^2.30.0", "debounce": "^1.2.1", + "gettext.js": "^2.0.2", "jquery": "^3.7.0", "normalize.css": "^8.0.1", "stimulus-autocomplete": "^3.1.0" @@ -6464,6 +6465,14 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -7454,6 +7463,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gettext-parser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-2.0.0.tgz", + "integrity": "sha512-FDs/7XjNw58ToQwJFO7avZZbPecSYgw8PBYhd0An+4JtZSrSzKhEvTsVV2uqdO7VziWTOGSgLGD5YRPdsCjF7Q==", + "dependencies": { + "encoding": "^0.1.12", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/gettext-to-messageformat": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/gettext-to-messageformat/-/gettext-to-messageformat-0.3.1.tgz", + "integrity": "sha512-UyqIL3Ul4NryU95Wome/qtlcuVIqgEWVIFw0zi7Lv14ACLXfaVDCbrjZ7o+3BZ7u+4NS1mP/2O1eXZoHCoas8g==", + "dependencies": { + "gettext-parser": "^1.4.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gettext-to-messageformat/node_modules/gettext-parser": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-1.4.0.tgz", + "integrity": "sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA==", + "dependencies": { + "encoding": "^0.1.12", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/gettext.js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/gettext.js/-/gettext.js-2.0.2.tgz", + "integrity": "sha512-V5S0HCgOzHmTaaHUNMQr3xVPu6qLpv3j6K9Ygi41Pu6SRNNmWwvXCf/wQYXArznm4uZyZ1v8yrDZngxy6+kCyw==", + "dependencies": { + "po2json": "^1.0.0-beta-3" + }, + "bin": { + "po2json-gettextjs": "bin/po2json" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -11639,6 +11688,35 @@ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" }, + "node_modules/po2json": { + "version": "1.0.0-beta-3", + "resolved": "https://registry.npmjs.org/po2json/-/po2json-1.0.0-beta-3.tgz", + "integrity": "sha512-taS8y6ZEGzPAs0rygW9CuUPY8C3Zgx6cBy31QXxG2JlWS3fLxj/kuD3cbIfXBg30PuYN7J5oyBa/TIRjyqFFtg==", + "dependencies": { + "commander": "^6.0.0", + "gettext-parser": "2.0.0", + "gettext-to-messageformat": "0.3.1" + }, + "bin": { + "po2json": "bin/po2json" + }, + "engines": { + "node": ">=10.0" + }, + "peerDependencies": { + "commander": "^6.0.0", + "gettext-parser": "2.0.0", + "gettext-to-messageformat": "0.3.1" + } + }, + "node_modules/po2json/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "engines": { + "node": ">= 6" + } + }, "node_modules/popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", diff --git a/package.json b/package.json index 41eace002f75..ff9b0123ce15 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "cookie": "^0.5.0", "date-fns": "^2.30.0", "debounce": "^1.2.1", + "gettext.js": "^2.0.2", "jquery": "^3.7.0", "normalize.css": "^8.0.1", "stimulus-autocomplete": "^3.1.0" diff --git a/warehouse/locale/am/LC_MESSAGES/messages.po b/warehouse/locale/am/LC_MESSAGES/messages.po index 4ec17d218ff9..e17a95ba58cf 100644 --- a/warehouse/locale/am/LC_MESSAGES/messages.po +++ b/warehouse/locale/am/LC_MESSAGES/messages.po @@ -22,10 +22,16 @@ msgstr "" "Generated-By: Babel 2.7.0\n" #: warehouse/views.py:142 +#: warehouse/views.js:142 msgid "" "You must verify your **primary** email address before you can perform this " "action." -msgstr "" +msgstr "some translation" + +#: warehouse/admin/static/js/warehouse.js:107 +#: warehouse/static/js/warehouse/controllers/clipboard_controller.js:32 +msgid "Copied" +msgstr "Copied translation" #: warehouse/views.py:158 msgid "" diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 0086f174724f..4f169bbecb5a 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -737,19 +737,19 @@ msgstr "" #: warehouse/static/js/warehouse/utils/timeago.js:33 msgid "Yesterday" -msgid_plural "About ${numDays} days ago" +msgid_plural "About %1 days ago" msgstr[0] "" msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:37 msgid "About an hour ago" -msgid_plural "About ${numHours} hours ago" +msgid_plural "About %1 hours ago" msgstr[0] "" msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:39 msgid "About a minute ago" -msgid_plural "About ${numMinutes} minutes ago" +msgid_plural "About %1 minutes ago" msgstr[0] "" msgstr[1] "" diff --git a/warehouse/static/js/warehouse/utils/fetch-gettext.js b/warehouse/static/js/warehouse/utils/messages-access.js similarity index 51% rename from warehouse/static/js/warehouse/utils/fetch-gettext.js rename to warehouse/static/js/warehouse/utils/messages-access.js index f307c54aa47a..de1627094e80 100644 --- a/warehouse/static/js/warehouse/utils/fetch-gettext.js +++ b/warehouse/static/js/warehouse/utils/messages-access.js @@ -11,12 +11,17 @@ * limitations under the License. */ -const fetchOptions = { - mode: "same-origin", - credentials: "same-origin", - cache: "default", - redirect: "follow", -}; +const i18n = require("gettext.js"); +const messages = require("./messages.json"); + +function determineLocale() { + // check cookie + const locale = document.cookie + .split("; ") + .find((row) => row.startsWith("_LOCALE_=")) + ?.split("=")[1]; + return locale ?? "en"; +} /** * Get the translation using num to choose the appropriate string. @@ -25,52 +30,30 @@ const fetchOptions = { * and have the translation strings extracted correctly. * Function 'gettext' for singular only extraction, function ngettext for singular and plural extraction. * - * This approach uses the server-side localizer to process the translation strings. - * - * Any placeholders must be specified as '${placeholderName}' surrounded by single or double quote, - * not backticks (template literal). + * Any placeholders must be specified as '%1', '%2', etc. * * @example - * import { gettext, ngettext } from "warehouse/utils/fetch-gettext"; + * import { gettext, ngettext } from "warehouse/utils/messages-access"; * // For a singular only string: * gettext("Just now"); * // For a singular and plural and placeholder string: - * ngettext("About a minute ago", "About ${numMinutes} minutes ago", numMinutes, {"numMinutes": numMinutes}); + * ngettext("About a minute ago", "About %1 minutes ago", numMinutes); * @param singular {string} The default string for the singular translation. * @param plural {string} The default string for the plural translation. * @param num {number} The number to use to select the appropriate translation. - * @param values {object} Key value pairs to fill the placeholders. - * @returns {Promise} The Fetch API promise. + * @param values {array[string]} Additional values to fill the placeholders. + * @returns {Promise} The promise. * @see https://www.gnu.org/software/gettext/manual/gettext.html#Language-specific-options * @see https://docs.pylonsproject.org/projects/pyramid/en/latest/api/i18n.html#pyramid.i18n.Localizer.pluralize */ -export function ngettext(singular, plural, num, values) { - let searchValues = {s: singular}; - if (plural) { - searchValues.p = plural; +export function ngettext(singular, plural, num, ...values) { + const locale = determineLocale(); + const json = messages.find((element) => element[""].language === locale); + if (json) { + i18n.loadJSON(json, "messages"); } - if (num !== undefined) { - searchValues.n = num; - } - if (values !== undefined) { - searchValues = {...searchValues, ...values}; - } - const searchParams = new URLSearchParams(searchValues); - return fetch("/translation/?" + searchParams.toString(), fetchOptions) - .then(response => { - if (response.ok) { - return response.json(); - } else { - throw new Error(`Unexpected response ${response.status}: ${response.body}.`); - } - }) - .then((json) => { - return json.msg; - }) - .catch(() => { - return ""; - }); + return Promise.resolve(i18n.ngettext(singular, plural, num, num, ...values)); } /** @@ -80,14 +63,12 @@ export function ngettext(singular, plural, num, values) { * and have the translation strings extracted correctly. * Function 'gettext' for singular only extraction, function ngettext for singular and plural extraction. * - * This approach uses the server-side localizer to process the translation strings. - * - * Any placeholders must be specified as '${placeholderName}' surrounded by single or double quote, - * not backticks (template literal). + * Any placeholders must be specified as '%1', '%2', etc. * * @param singular {string} The default string for the singular translation. - * @returns {Promise} The Fetch API promise. + * @param values {array[string]} Additional values to fill the placeholders. + * @returns {Promise} The promise. */ -export function gettext(singular) { - return ngettext(singular, undefined, undefined, undefined); +export function gettext(singular, ...values) { + return Promise.resolve(i18n.gettext(singular, ...values)); } diff --git a/warehouse/static/js/warehouse/utils/messages.json b/warehouse/static/js/warehouse/utils/messages.json new file mode 100644 index 000000000000..4b873eb1ef5f --- /dev/null +++ b/warehouse/static/js/warehouse/utils/messages.json @@ -0,0 +1,10 @@ +[ + { + "": { + "language": "am", + "plural-forms": "nplurals=2; plural=n > 1;" + }, + "You must verify your **primary** email address before you can perform this action.": "some translation", + "Copied": "Copied translation" + } +] diff --git a/warehouse/static/js/warehouse/utils/timeago.js b/warehouse/static/js/warehouse/utils/timeago.js index ab5cf9f68a1f..5e572150aa0e 100644 --- a/warehouse/static/js/warehouse/utils/timeago.js +++ b/warehouse/static/js/warehouse/utils/timeago.js @@ -11,7 +11,7 @@ * limitations under the License. */ -import { gettext, ngettext } from "./fetch-gettext"; +import { gettext, ngettext } from "./messages-access"; const enumerateTime = (timestampString) => { const now = new Date(), @@ -30,13 +30,13 @@ const convertToReadableText = async (time) => { let { numDays, numMinutes, numHours } = time; if (numDays >= 1) { - return ngettext("Yesterday", "About ${numDays} days ago", numDays, {"numDays": numDays}); + return ngettext("Yesterday", "About %1 days ago", numDays, {"numDays": numDays}); } if (numHours > 0) { - return ngettext("About an hour ago", "About ${numHours} hours ago", numHours, {"numHours": numHours}); + return ngettext("About an hour ago", "About %1 hours ago", numHours, {"numHours": numHours}); } else if (numMinutes > 0) { - return ngettext("About a minute ago", "About ${numMinutes} minutes ago", numMinutes, {"numMinutes": numMinutes}); + return ngettext("About a minute ago", "About %1 minutes ago", numMinutes, {"numMinutes": numMinutes}); } else { return gettext("Just now"); } From 67b771ff5818199ed501c7ac2bd0675b237bf64f Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Thu, 18 Apr 2024 20:06:45 +1000 Subject: [PATCH 26/52] check point --- Dockerfile | 1 + Makefile | 1 - bin/translations | 1 + bin/{translations-js => translations-js.mjs} | 17 +- bin/translations_json.py | 76 ++ docs/dev/translations.rst | 1 + package-lock.json | 930 +++++++++--------- package.json | 2 +- requirements/dev.txt | 3 +- tests/frontend/clipboard_controller_test.js | 11 +- .../password_match_controller_test.js | 39 +- ...password_strength_gauge_controller_test.js | 25 - tests/frontend/timeago_test.js | 35 +- tests/unit/test_routes.py | 1 - tests/unit/test_views.py | 125 --- warehouse/admin/static/js/warehouse.js | 7 +- warehouse/admin/templates/admin/base.html | 2 +- warehouse/i18n/__init__.py | 4 +- warehouse/locale/de/LC_MESSAGES/messages.json | 1 + warehouse/locale/el/LC_MESSAGES/messages.json | 1 + warehouse/locale/eo/LC_MESSAGES/messages.json | 1 + warehouse/locale/es/LC_MESSAGES/messages.json | 1 + warehouse/locale/fr/LC_MESSAGES/messages.json | 110 +++ warehouse/locale/fr/LC_MESSAGES/messages.po | 43 + warehouse/locale/he/LC_MESSAGES/messages.json | 1 + warehouse/locale/ja/LC_MESSAGES/messages.json | 1 + warehouse/locale/messages.pot | 10 +- .../locale/pt_BR/LC_MESSAGES/messages.json | 1 + warehouse/locale/ru/LC_MESSAGES/messages.json | 1 + warehouse/locale/uk/LC_MESSAGES/messages.json | 1 + .../locale/zh_Hans/LC_MESSAGES/messages.json | 1 + .../locale/zh_Hant/LC_MESSAGES/messages.json | 1 + warehouse/routes.py | 1 - .../controllers/clipboard_controller.js | 8 +- .../controllers/password_breach_controller.js | 7 +- .../controllers/password_match_controller.js | 10 +- .../password_strength_gauge_controller.js | 15 +- .../js/warehouse/utils/messages-access.js | 52 +- .../static/js/warehouse/utils/messages.json | 10 - .../static/js/warehouse/utils/timeago.js | 15 +- warehouse/templates/base.html | 2 +- warehouse/views.py | 27 - webpack.config.js | 70 +- 43 files changed, 859 insertions(+), 813 deletions(-) rename bin/{translations-js => translations-js.mjs} (87%) create mode 100644 bin/translations_json.py create mode 100644 warehouse/locale/de/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/el/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/eo/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/es/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/fr/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/he/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/ja/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/pt_BR/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/ru/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/uk/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/zh_Hans/LC_MESSAGES/messages.json create mode 100644 warehouse/locale/zh_Hant/LC_MESSAGES/messages.json delete mode 100644 warehouse/static/js/warehouse/utils/messages.json diff --git a/Dockerfile b/Dockerfile index 266ba0260644..a8d290a0682b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ FROM static-deps as static # small amount of copying when only `webpack.config.js` is modified. COPY warehouse/static/ /opt/warehouse/src/warehouse/static/ COPY warehouse/admin/static/ /opt/warehouse/src/warehouse/admin/static/ +COPY warehouse/locale/ /opt/warehouse/src/warehouse/locale/ COPY webpack.config.js /opt/warehouse/src/ RUN NODE_ENV=production npm run build diff --git a/Makefile b/Makefile index 1329a44ee1f1..6b392a1e9fa0 100644 --- a/Makefile +++ b/Makefile @@ -95,7 +95,6 @@ deps: .state/docker-build-base translations: .state/docker-build-base .state/docker-build-static docker compose run --rm base bin/translations - docker compose run --rm static node bin/translations-js warehouse/locale requirements/%.txt: requirements/%.in docker compose run --rm base bin/pip-compile --generate-hashes --output-file=$@ $< diff --git a/bin/translations b/bin/translations index 335d861b1c8c..e8bb0a81c972 100755 --- a/bin/translations +++ b/bin/translations @@ -11,3 +11,4 @@ export LANG="${ENCODING:-en_US.UTF-8}" set -x make -C warehouse/locale/ translations +python bin/translations_json.py diff --git a/bin/translations-js b/bin/translations-js.mjs similarity index 87% rename from bin/translations-js rename to bin/translations-js.mjs index 475b805e5940..4b2b8a09044d 100644 --- a/bin/translations-js +++ b/bin/translations-js.mjs @@ -1,10 +1,9 @@ #!/usr/bin/env node /* - po2json wrapper for gettext.js - https://github.com/mikeedwards/po2json - - based on https://github.com/guillaumepotier/gettext.js/blob/v2.0.2/bin/po2json + based on: + - po2json wrapper for gettext.js https://github.com/mikeedwards/po2json + - based on https://github.com/guillaumepotier/gettext.js/blob/v2.0.2/bin/po2json Dump all .po files in one json file containing an array with entries like this one: @@ -25,15 +24,17 @@ */ -const {readFile, writeFile, readdir, stat} = require("node:fs/promises"); -const {resolve} = require("node:path"); -const gettextParser = require("gettext-parser"); + +import {readdir, readFile, stat, writeFile} from "node:fs/promises"; +import {resolve} from "node:path"; +import {po} from "gettext-parser"; + const argv = process.argv; const runPo2Json = async function (filePath) { const buffer = await readFile(filePath); - const jsonData = gettextParser.po.parse(buffer, "utf-8"); + const jsonData = po.parse(buffer, {defaultCharset:"utf-8", validation: false }); // Build the format expected by gettext.js. // Includes only translations from .js files. diff --git a/bin/translations_json.py b/bin/translations_json.py new file mode 100644 index 000000000000..89c22c68f6a4 --- /dev/null +++ b/bin/translations_json.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import gettext +import json +import pathlib + +import polib + +""" + +""" + +domain = "messages" +localedir = "warehouse/locale" +languages = [ + "es", + "fr", + "ja", + "pt_BR", + "uk", + "el", + "de", + "zh_Hans", + "zh_Hant", + "ru", + "he", + "eo", +] + +# look in each language file that is used by the app +for lang in languages: + # read the .po file to find any .js file messages + entries = [] + include_next = False + + mo_file = gettext.find(domain, localedir=localedir, languages=[lang]) + po_path = pathlib.Path(mo_file).with_suffix('.po') + po = polib.pofile(po_path) + for entry in po.translated_entries(): + occurs_in_js = any(o.endswith('.js') for o, _ in entry.occurrences) + if occurs_in_js: + entries.append(entry) + + # if one or more translation messages from javascript files were found, + # then write the json file to the same folder. + result = { + "plural-forms": po.metadata['Plural-Forms'], + "entries": {((e.msgid),{ + "flags": e.flags, + "msgctxt": e.msgctxt, + "msgid": e.msgid, + "msgid_plural": e.msgid_plural, + "msgid_with_context": e.msgid_with_context, + "msgstr": e.msgstr, + "msgstr_plural": e.msgstr_plural, + "prev_msgctxt": e.previous_msgctxt, + "prev_msgid": e.previous_msgid, + "prev_msgid_plural": e.previous_msgid_plural, + }) for e in entries}, + } + json_path = po_path.with_suffix('.json') + with json_path.open('w') as f: + print(f"Writing messages to {json_path}") + json.dump(result, f) diff --git a/docs/dev/translations.rst b/docs/dev/translations.rst index bf5f67316d04..3d88cabccc1d 100644 --- a/docs/dev/translations.rst +++ b/docs/dev/translations.rst @@ -62,6 +62,7 @@ In Python, given a request context, call :code:`request._(message)` to mark from warehouse.i18n import localize as _ message = _("Your message here.") +In javascript, Passing non-translatable values to translated strings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/package-lock.json b/package-lock.json index 923e1da53fdf..0a850172e144 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "cookie": "^0.5.0", "date-fns": "^2.30.0", "debounce": "^1.2.1", - "gettext.js": "^2.0.2", "jquery": "^3.7.0", "normalize.css": "^8.0.1", "stimulus-autocomplete": "^3.1.0" @@ -49,6 +48,7 @@ "webpack": "^5.80.0", "webpack-cli": "^5.0.2", "webpack-livereload-plugin": "^3.0.2", + "webpack-localize-assets-plugin": "^1.5.4", "webpack-manifest-plugin": "^5.0.0", "webpack-remove-empty-scripts": "^1.0.3" } @@ -82,42 +82,42 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", "dev": true, "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", - "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.4", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.0", - "@babel/parser": "^7.24.0", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.0", + "@babel/traverse": "^7.24.1", "@babel/types": "^7.24.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -152,14 +152,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", "dev": true, "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "engines": { @@ -207,9 +207,9 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.0.tgz", - "integrity": "sha512-QAH+vfvts51BCsNZ2PhY6HAggnlS6omLLFTsIpeqZk/MmJ6cW7tgz5yRv0fMJThcr6FmbMrENh1RgrWPTYA76g==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.4.tgz", + "integrity": "sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", @@ -217,7 +217,7 @@ "@babel/helper-function-name": "^7.23.0", "@babel/helper-member-expression-to-functions": "^7.23.0", "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", + "@babel/helper-replace-supers": "^7.24.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", "semver": "^6.3.1" @@ -309,12 +309,12 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", + "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", "dev": true, "dependencies": { - "@babel/types": "^7.22.15" + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -378,13 +378,13 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", - "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.1.tgz", + "integrity": "sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.22.15", + "@babel/helper-member-expression-to-functions": "^7.23.0", "@babel/helper-optimise-call-expression": "^7.22.5" }, "engines": { @@ -472,13 +472,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.0.tgz", - "integrity": "sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", "dev": true, "dependencies": { "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.0", + "@babel/traverse": "^7.24.1", "@babel/types": "^7.24.0" }, "engines": { @@ -486,23 +486,24 @@ } }, "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", - "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -511,13 +512,29 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.4.tgz", + "integrity": "sha512-qpl6vOOEEzTLLcsuqYYo8yDtrTocmu2xkGvgNebvPjT9DTtfFYGmgDqY+rBYXNlqL4s9qLDn6xkrJv4RxAPiTA==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz", - "integrity": "sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.1.tgz", + "integrity": "sha512-y4HqEnkelJIOQGd+3g1bTeKsA5c6qM7eOn7VggGVbBc0y8MLSKHacwcIE2PplNlQSj0PqS9rrXL/nkPVK+kUNg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -527,14 +544,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz", - "integrity": "sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.1.tgz", + "integrity": "sha512-Hj791Ii4ci8HqnaKHAlLNs+zaLXb0EzSDhiAWp5VNlyvCNymYfacs64pxTxbH1znW/NcArSmwpmG9IKE/TUVVQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.23.3" + "@babel/plugin-transform-optional-chaining": "^7.24.1" }, "engines": { "node": ">=6.9.0" @@ -544,13 +561,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.7.tgz", - "integrity": "sha512-LlRT7HgaifEpQA1ZgLVOIJZZFVPWN5iReq/7/JixwBtwcoeVGDBD53ZV28rrsLYOZs1Y/EHhA8N/Z6aazHR8cw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.24.1.tgz", + "integrity": "sha512-m9m/fXsXLiHfwdgydIFnpk+7jlVbnvlK5B2EKiPdLUb6WX654ZaaEWJUjk8TftRbZpK0XibovlLWX4KIZhV6jw==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -647,12 +664,12 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz", - "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.1.tgz", + "integrity": "sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -662,12 +679,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz", - "integrity": "sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.1.tgz", + "integrity": "sha512-zhQTMH0X2nVLnb04tz+s7AMuasX8U0FnpE+nHTOhSOINjWMnopoZTxtIKsd45n4GQ/HIZLyfIpoul8e2m0DnRA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -849,12 +866,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz", - "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.1.tgz", + "integrity": "sha512-ngT/3NkRhsaep9ck9uj2Xhv9+xB1zShY3tM3g6om4xxCELwCDN4g4Aq5dRn48+0hasAql7s2hdBOysCfNpr4fw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -864,13 +881,13 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.9.tgz", - "integrity": "sha512-8Q3veQEDGe14dTYuwagbRtwxQDnytyg1JFu4/HwEMETeofocrB0U0ejBJIXoeG/t2oXZ8kzCyI0ZZfbT80VFNQ==", + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.24.3.tgz", + "integrity": "sha512-Qe26CMYVjpQxJ8zxM1340JFNjZaF+ISWpr1Kt/jGo+ZTUzKkfw/pphEWbRCb+lmSM6k/TOgfYLvmbHkUQ0asIg==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-remap-async-to-generator": "^7.22.20", "@babel/plugin-syntax-async-generators": "^7.8.4" }, @@ -882,13 +899,13 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", - "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.1.tgz", + "integrity": "sha512-AawPptitRXp1y0n4ilKcGbRYWfbbzFWz2NqNu7dacYDtFtz0CMjG64b3LQsb3KIgnf4/obcUL78hfaOS7iCUfw==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-module-imports": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-remap-async-to-generator": "^7.22.20" }, "engines": { @@ -899,12 +916,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz", - "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.1.tgz", + "integrity": "sha512-TWWC18OShZutrv9C6mye1xwtam+uNi2bnTOCBUd5sZxyHOiWbU6ztSROofIMrK84uweEZC219POICK/sTYwfgg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -914,12 +931,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz", - "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.4.tgz", + "integrity": "sha512-nIFUZIpGKDf9O9ttyRXpHFpKC+X3Y5mtshZONuEUYBomAKoM4y029Jr+uB1bHGPhNmK8YXHevDtKDOLmtRrp6g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -929,13 +946,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz", - "integrity": "sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.1.tgz", + "integrity": "sha512-OMLCXi0NqvJfORTaPQBwqLXHhb93wkBKZ4aNwMl6WtehO7ar+cmp+89iPEQPqxAnxsOKTaMcs3POz3rKayJ72g==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -945,13 +962,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", - "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.4.tgz", + "integrity": "sha512-B8q7Pz870Hz/q9UgP8InNpY01CSLDSCyqX7zcRuv3FcPl87A2G17lASroHWaCtbdIcbYzOZ7kWmXFKbijMSmFg==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.24.4", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-class-static-block": "^7.14.5" }, "engines": { @@ -962,17 +979,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.8.tgz", - "integrity": "sha512-yAYslGsY1bX6Knmg46RjiCiNSwJKv2IUC8qOdYKqMMr0491SXFhcHqOdRDeCRohOOIzwN/90C6mQ9qAKgrP7dg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.1.tgz", + "integrity": "sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-replace-supers": "^7.24.1", "@babel/helper-split-export-declaration": "^7.22.6", "globals": "^11.1.0" }, @@ -984,13 +1001,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", - "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.1.tgz", + "integrity": "sha512-5pJGVIUfJpOS+pAqBQd+QMaTD2vCL/HcePooON6pDpHgRp4gNRmzyHTPIkXntwKsq3ayUFVfJaIKPw2pOkOcTw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/template": "^7.22.15" + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/template": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1000,12 +1017,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz", - "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.1.tgz", + "integrity": "sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1015,13 +1032,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz", - "integrity": "sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.1.tgz", + "integrity": "sha512-p7uUxgSoZwZ2lPNMzUkqCts3xlp8n+o05ikjy7gbtFJSt9gdU88jAmtfmOxHM14noQXBxfgzf2yRWECiNVhTCw==", "dev": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1031,12 +1048,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz", - "integrity": "sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.1.tgz", + "integrity": "sha512-msyzuUnvsjsaSaocV6L7ErfNsa5nDWL1XKNnDePLgmz+WdU4w/J8+AxBMrWfi9m4IxfL5sZQKUPQKDQeeAT6lA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1046,12 +1063,12 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz", - "integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.1.tgz", + "integrity": "sha512-av2gdSTyXcJVdI+8aFZsCAtR29xJt0S5tas+Ef8NvBNmD1a+N/3ecMLeMBgfcK+xzsjdLDT6oHt+DFPyeqUbDA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-dynamic-import": "^7.8.3" }, "engines": { @@ -1062,13 +1079,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz", - "integrity": "sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.1.tgz", + "integrity": "sha512-U1yX13dVBSwS23DEAqU+Z/PkwE9/m7QQy8Y9/+Tdb8UWYaGNDYwTLi19wqIAiROr8sXVum9A/rtiH5H0boUcTw==", "dev": true, "dependencies": { "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1078,12 +1095,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz", - "integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.1.tgz", + "integrity": "sha512-Ft38m/KFOyzKw2UaJFkWG9QnHPG/Q/2SkOrRk4pNBPg5IPZ+dOxcmkK5IyuBcxiNPyyYowPGUReyBvrvZs7IlQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-export-namespace-from": "^7.8.3" }, "engines": { @@ -1094,12 +1111,12 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz", - "integrity": "sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.1.tgz", + "integrity": "sha512-OxBdcnF04bpdQdR3i4giHZNZQn7cm8RQKcSwA17wAAqEELo1ZOwp5FFgeptWUQXFyT9kwHo10aqqauYkRZPCAg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" }, "engines": { @@ -1110,14 +1127,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz", - "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.24.1.tgz", + "integrity": "sha512-BXmDZpPlh7jwicKArQASrj8n22/w6iymRnvHYYd2zO30DbE277JO20/7yXJT3QxDPtiQiOxQBbZH4TpivNXIxA==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1127,12 +1144,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz", - "integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.1.tgz", + "integrity": "sha512-U7RMFmRvoasscrIFy5xA4gIp8iWnWubnKkKuUGJjsuOH7GfbMkB+XZzeslx2kLdEGdOJDamEmCqOks6e8nv8DQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-json-strings": "^7.8.3" }, "engines": { @@ -1143,12 +1160,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz", - "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.1.tgz", + "integrity": "sha512-zn9pwz8U7nCqOYIiBaOxoQOtYmMODXTJnkxG4AtX8fPmnCRYWBOHD0qcpwS9e2VDSp1zNJYpdnFMIKb8jmwu6g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1158,12 +1175,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz", - "integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.1.tgz", + "integrity": "sha512-OhN6J4Bpz+hIBqItTeWJujDOfNP+unqv/NJgyhlpSqgBTPm37KkMmZV6SYcOj+pnDbdcl1qRGV/ZiIjX9Iy34w==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" }, "engines": { @@ -1174,12 +1191,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz", - "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.1.tgz", + "integrity": "sha512-4ojai0KysTWXzHseJKa1XPNXKRbuUrhkOPY4rEGeR+7ChlJVKxFa3H3Bz+7tWaGKgJAXUWKOGmltN+u9B3+CVg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1189,13 +1206,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz", - "integrity": "sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.1.tgz", + "integrity": "sha512-lAxNHi4HVtjnHd5Rxg3D5t99Xm6H7b04hUS7EHIXcUl2EV4yl1gWdqZrNzXnSrHveL9qMdbODlLF55mvgjAfaQ==", "dev": true, "dependencies": { "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1205,13 +1222,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", - "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.1.tgz", + "integrity": "sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==", "dev": true, "dependencies": { "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-simple-access": "^7.22.5" }, "engines": { @@ -1222,14 +1239,14 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.9.tgz", - "integrity": "sha512-KDlPRM6sLo4o1FkiSlXoAa8edLXFsKKIda779fbLrvmeuc3itnjCtaO6RrtoaANsIJANj+Vk1zqbZIMhkCAHVw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.24.1.tgz", + "integrity": "sha512-mqQ3Zh9vFO1Tpmlt8QPnbwGHzNz3lpNEMxQb1kAemn/erstyqw1r9KeOlOfo3y6xAnFEcOv2tSyrXfmMk+/YZA==", "dev": true, "dependencies": { "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-validator-identifier": "^7.22.20" }, "engines": { @@ -1240,13 +1257,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz", - "integrity": "sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.1.tgz", + "integrity": "sha512-tuA3lpPj+5ITfcCluy6nWonSL7RvaG0AOTeAuvXqEKS34lnLzXpDb0dcP6K8jD0zWZFNDVly90AGFJPnm4fOYg==", "dev": true, "dependencies": { "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1272,12 +1289,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz", - "integrity": "sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.1.tgz", + "integrity": "sha512-/rurytBM34hYy0HKZQyA0nHbQgQNFm4Q/BOc9Hflxi2X3twRof7NaE5W46j4kQitm7SvACVRXsa6N/tSZxvPug==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1287,12 +1304,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz", - "integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.1.tgz", + "integrity": "sha512-iQ+caew8wRrhCikO5DrUYx0mrmdhkaELgFa+7baMcVuhxIkN7oxt06CZ51D65ugIb1UWRQ8oQe+HXAVM6qHFjw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" }, "engines": { @@ -1303,12 +1320,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz", - "integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.1.tgz", + "integrity": "sha512-7GAsGlK4cNL2OExJH1DzmDeKnRv/LXq0eLUSvudrehVA5Rgg4bIrqEUW29FbKMBRT0ztSqisv7kjP+XIC4ZMNw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-numeric-separator": "^7.10.4" }, "engines": { @@ -1319,16 +1336,15 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.0.tgz", - "integrity": "sha512-y/yKMm7buHpFFXfxVFS4Vk1ToRJDilIa6fKRioB9Vjichv58TDGXTvqV0dN7plobAmTW5eSEGXDngE+Mm+uO+w==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.1.tgz", + "integrity": "sha512-XjD5f0YqOtebto4HGISLNfiNMTTs6tbkFf2TOqJlYKYmbo+mN9Dnpl4SRoofiziuOWMIyq3sZEUqLo3hLITFEA==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.5", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.23.3" + "@babel/plugin-transform-parameters": "^7.24.1" }, "engines": { "node": ">=6.9.0" @@ -1338,13 +1354,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz", - "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.1.tgz", + "integrity": "sha512-oKJqR3TeI5hSLRxudMjFQ9re9fBVUU0GICqM3J1mi8MqlhVr6hC/ZN4ttAyMuQR6EZZIY6h/exe5swqGNNIkWQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20" + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-replace-supers": "^7.24.1" }, "engines": { "node": ">=6.9.0" @@ -1354,12 +1370,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz", - "integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.1.tgz", + "integrity": "sha512-oBTH7oURV4Y+3EUrf6cWn1OHio3qG/PVwO5J03iSJmBg6m2EhKjkAu/xuaXaYwWW9miYtvbWv4LNf0AmR43LUA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" }, "engines": { @@ -1370,12 +1386,12 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz", - "integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.1.tgz", + "integrity": "sha512-n03wmDt+987qXwAgcBlnUUivrZBPZ8z1plL0YvgQalLm+ZE5BMhGm94jhxXtA1wzv1Cu2aaOv1BM9vbVttrzSg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, @@ -1387,12 +1403,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz", - "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.1.tgz", + "integrity": "sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1402,13 +1418,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz", - "integrity": "sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.1.tgz", + "integrity": "sha512-tGvisebwBO5em4PaYNqt4fkw56K2VALsAbAakY0FjTYqJp7gfdrgr7YX76Or8/cpik0W6+tj3rZ0uHU9Oil4tw==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1418,14 +1434,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz", - "integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.1.tgz", + "integrity": "sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-private-property-in-object": "^7.14.5" }, "engines": { @@ -1436,12 +1452,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz", - "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.1.tgz", + "integrity": "sha512-LetvD7CrHmEx0G442gOomRr66d7q8HzzGGr4PMHGr+5YIm6++Yke+jxj246rpvsbyhJwCLxcTn6zW1P1BSenqA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1451,12 +1467,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz", - "integrity": "sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.1.tgz", + "integrity": "sha512-sJwZBCzIBE4t+5Q4IGLaaun5ExVMRY0lYwos/jNecjMrVCygCdph3IKv0tkP5Fc87e/1+bebAmEAGBfnRD+cnw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "regenerator-transform": "^0.15.2" }, "engines": { @@ -1467,12 +1483,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz", - "integrity": "sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.1.tgz", + "integrity": "sha512-JAclqStUfIwKN15HrsQADFgeZt+wexNQ0uLhuqvqAUFoqPMjEcFCYZBhq0LUdz6dZK/mD+rErhW71fbx8RYElg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1482,12 +1498,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz", - "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.1.tgz", + "integrity": "sha512-LyjVB1nsJ6gTTUKRjRWx9C1s9hE7dLfP/knKdrfeH9UPtAGjYGgxIbFfx7xyLIEWs7Xe1Gnf8EWiUqfjLhInZA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1497,12 +1513,12 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz", - "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.1.tgz", + "integrity": "sha512-KjmcIM+fxgY+KxPVbjelJC6hrH1CgtPmTvdXAfn3/a9CnWGSTY7nH4zm5+cjmWJybdcPSsD0++QssDsjcpe47g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" }, "engines": { @@ -1513,12 +1529,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz", - "integrity": "sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.1.tgz", + "integrity": "sha512-9v0f1bRXgPVcPrngOQvLXeGNNVLc8UjMVfebo9ka0WF3/7+aVUHmaJVT3sa0XCzEFioPfPHZiOcYG9qOsH63cw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1528,12 +1544,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz", - "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.1.tgz", + "integrity": "sha512-WRkhROsNzriarqECASCNu/nojeXCDTE/F2HmRgOzi7NGvyfYGq1NEjKBK3ckLfRgGc6/lPAqP0vDOSw3YtG34g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1543,12 +1559,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz", - "integrity": "sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.1.tgz", + "integrity": "sha512-CBfU4l/A+KruSUoW+vTQthwcAdwuqbpRNB8HQKlZABwHRhsdHZ9fezp4Sn18PeAlYxTNiLMlx4xUBV3AWfg1BA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1558,12 +1574,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz", - "integrity": "sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.1.tgz", + "integrity": "sha512-RlkVIcWT4TLI96zM660S877E7beKlQw7Ig+wqkKBiWfj0zH5Q4h50q6er4wzZKRNSYpfo6ILJ+hrJAGSX2qcNw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1573,13 +1589,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz", - "integrity": "sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.1.tgz", + "integrity": "sha512-Ss4VvlfYV5huWApFsF8/Sq0oXnGO+jB+rijFEFugTd3cwSObUSnUi88djgR5528Csl0uKlrI331kRqe56Ov2Ng==", "dev": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1589,13 +1605,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz", - "integrity": "sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.1.tgz", + "integrity": "sha512-2A/94wgZgxfTsiLaQ2E36XAOdcZmGAaEEgVmxQWwZXWkGhvoHbaqXcKnU8zny4ycpu3vNqg0L/PcCiYtHtA13g==", "dev": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1605,13 +1621,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz", - "integrity": "sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.1.tgz", + "integrity": "sha512-fqj4WuzzS+ukpgerpAoOnMfQXwUHFxXUZUE84oL2Kao2N8uSlvcpnAidKASgsNgzZHBsHWvcm8s9FPWUhAb8fA==", "dev": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -1621,26 +1637,27 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.0.tgz", - "integrity": "sha512-ZxPEzV9IgvGn73iK0E6VB9/95Nd7aMFpbE0l8KQFDG70cOV9IxRP7Y2FUPmlK0v6ImlLqYX50iuZ3ZTVhOF2lA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.4.tgz", + "integrity": "sha512-7Kl6cSmYkak0FK/FXjSEnLJ1N9T/WA2RkMhu17gZ/dsxKJUuTYNIylahPTzqpLyJN4WhDif8X0XK1R8Wsguo/A==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.5", + "@babel/compat-data": "^7.24.4", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-plugin-utils": "^7.24.0", "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.24.4", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.24.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.24.1", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.23.3", - "@babel/plugin-syntax-import-attributes": "^7.23.3", + "@babel/plugin-syntax-import-assertions": "^7.24.1", + "@babel/plugin-syntax-import-attributes": "^7.24.1", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", @@ -1652,58 +1669,58 @@ "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.23.3", - "@babel/plugin-transform-async-generator-functions": "^7.23.9", - "@babel/plugin-transform-async-to-generator": "^7.23.3", - "@babel/plugin-transform-block-scoped-functions": "^7.23.3", - "@babel/plugin-transform-block-scoping": "^7.23.4", - "@babel/plugin-transform-class-properties": "^7.23.3", - "@babel/plugin-transform-class-static-block": "^7.23.4", - "@babel/plugin-transform-classes": "^7.23.8", - "@babel/plugin-transform-computed-properties": "^7.23.3", - "@babel/plugin-transform-destructuring": "^7.23.3", - "@babel/plugin-transform-dotall-regex": "^7.23.3", - "@babel/plugin-transform-duplicate-keys": "^7.23.3", - "@babel/plugin-transform-dynamic-import": "^7.23.4", - "@babel/plugin-transform-exponentiation-operator": "^7.23.3", - "@babel/plugin-transform-export-namespace-from": "^7.23.4", - "@babel/plugin-transform-for-of": "^7.23.6", - "@babel/plugin-transform-function-name": "^7.23.3", - "@babel/plugin-transform-json-strings": "^7.23.4", - "@babel/plugin-transform-literals": "^7.23.3", - "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", - "@babel/plugin-transform-member-expression-literals": "^7.23.3", - "@babel/plugin-transform-modules-amd": "^7.23.3", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-modules-systemjs": "^7.23.9", - "@babel/plugin-transform-modules-umd": "^7.23.3", + "@babel/plugin-transform-arrow-functions": "^7.24.1", + "@babel/plugin-transform-async-generator-functions": "^7.24.3", + "@babel/plugin-transform-async-to-generator": "^7.24.1", + "@babel/plugin-transform-block-scoped-functions": "^7.24.1", + "@babel/plugin-transform-block-scoping": "^7.24.4", + "@babel/plugin-transform-class-properties": "^7.24.1", + "@babel/plugin-transform-class-static-block": "^7.24.4", + "@babel/plugin-transform-classes": "^7.24.1", + "@babel/plugin-transform-computed-properties": "^7.24.1", + "@babel/plugin-transform-destructuring": "^7.24.1", + "@babel/plugin-transform-dotall-regex": "^7.24.1", + "@babel/plugin-transform-duplicate-keys": "^7.24.1", + "@babel/plugin-transform-dynamic-import": "^7.24.1", + "@babel/plugin-transform-exponentiation-operator": "^7.24.1", + "@babel/plugin-transform-export-namespace-from": "^7.24.1", + "@babel/plugin-transform-for-of": "^7.24.1", + "@babel/plugin-transform-function-name": "^7.24.1", + "@babel/plugin-transform-json-strings": "^7.24.1", + "@babel/plugin-transform-literals": "^7.24.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.1", + "@babel/plugin-transform-member-expression-literals": "^7.24.1", + "@babel/plugin-transform-modules-amd": "^7.24.1", + "@babel/plugin-transform-modules-commonjs": "^7.24.1", + "@babel/plugin-transform-modules-systemjs": "^7.24.1", + "@babel/plugin-transform-modules-umd": "^7.24.1", "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.23.3", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", - "@babel/plugin-transform-numeric-separator": "^7.23.4", - "@babel/plugin-transform-object-rest-spread": "^7.24.0", - "@babel/plugin-transform-object-super": "^7.23.3", - "@babel/plugin-transform-optional-catch-binding": "^7.23.4", - "@babel/plugin-transform-optional-chaining": "^7.23.4", - "@babel/plugin-transform-parameters": "^7.23.3", - "@babel/plugin-transform-private-methods": "^7.23.3", - "@babel/plugin-transform-private-property-in-object": "^7.23.4", - "@babel/plugin-transform-property-literals": "^7.23.3", - "@babel/plugin-transform-regenerator": "^7.23.3", - "@babel/plugin-transform-reserved-words": "^7.23.3", - "@babel/plugin-transform-shorthand-properties": "^7.23.3", - "@babel/plugin-transform-spread": "^7.23.3", - "@babel/plugin-transform-sticky-regex": "^7.23.3", - "@babel/plugin-transform-template-literals": "^7.23.3", - "@babel/plugin-transform-typeof-symbol": "^7.23.3", - "@babel/plugin-transform-unicode-escapes": "^7.23.3", - "@babel/plugin-transform-unicode-property-regex": "^7.23.3", - "@babel/plugin-transform-unicode-regex": "^7.23.3", - "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", + "@babel/plugin-transform-new-target": "^7.24.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.1", + "@babel/plugin-transform-numeric-separator": "^7.24.1", + "@babel/plugin-transform-object-rest-spread": "^7.24.1", + "@babel/plugin-transform-object-super": "^7.24.1", + "@babel/plugin-transform-optional-catch-binding": "^7.24.1", + "@babel/plugin-transform-optional-chaining": "^7.24.1", + "@babel/plugin-transform-parameters": "^7.24.1", + "@babel/plugin-transform-private-methods": "^7.24.1", + "@babel/plugin-transform-private-property-in-object": "^7.24.1", + "@babel/plugin-transform-property-literals": "^7.24.1", + "@babel/plugin-transform-regenerator": "^7.24.1", + "@babel/plugin-transform-reserved-words": "^7.24.1", + "@babel/plugin-transform-shorthand-properties": "^7.24.1", + "@babel/plugin-transform-spread": "^7.24.1", + "@babel/plugin-transform-sticky-regex": "^7.24.1", + "@babel/plugin-transform-template-literals": "^7.24.1", + "@babel/plugin-transform-typeof-symbol": "^7.24.1", + "@babel/plugin-transform-unicode-escapes": "^7.24.1", + "@babel/plugin-transform-unicode-property-regex": "^7.24.1", + "@babel/plugin-transform-unicode-regex": "^7.24.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.24.1", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.8", - "babel-plugin-polyfill-corejs3": "^0.9.0", - "babel-plugin-polyfill-regenerator": "^0.5.5", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.4", + "babel-plugin-polyfill-regenerator": "^0.6.1", "core-js-compat": "^3.31.0", "semver": "^6.3.1" }, @@ -1760,18 +1777,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.0.tgz", - "integrity": "sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.0", + "@babel/parser": "^7.24.1", "@babel/types": "^7.24.0", "debug": "^4.3.1", "globals": "^11.1.0" @@ -3470,6 +3487,14 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -4270,6 +4295,15 @@ "node": ">=8" } }, + "node_modules/astring": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz", + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "dev": true, + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -4422,72 +4456,75 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", - "integrity": "sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==", + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.1", - "semver": "^6.3.1" + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "engines": { + "node": ">=10", + "npm": ">=6" } }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.9.0.tgz", - "integrity": "sha512-7nZPG1uzK2Ymhy/NbaOWTg3uibM2BmGASS4vHS4szRZAIR8R6GwA/xAujpdrXU5iyklrimWnLWU+BLF9suPTqg==", + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.5.0", - "core-js-compat": "^3.34.0" + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "engines": { + "node": ">=10" } }, - "node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", + "integrity": "sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.1", + "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", - "integrity": "sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==", + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz", + "integrity": "sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.5.0" + "@babel/helper-define-polyfill-provider": "^0.6.1", + "core-js-compat": "^3.36.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-polyfill-regenerator/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.1.tgz", + "integrity": "sha512-JfTApdE++cgcTWjsiCQlLyFBMbTUft9ja17saCc93lgV33h4tuCVj7tlvu//qpLwaG+3yEz7/KhahGrUMkVq9g==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "@babel/helper-define-polyfill-provider": "^0.6.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -5294,12 +5331,12 @@ } }, "node_modules/core-js-compat": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz", - "integrity": "sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw==", + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.1.tgz", + "integrity": "sha512-Dk997v9ZCt3X/npqzyGdTlq6t7lDBhZwGvV94PKzDArjp7BTRm7WlDAXYd/OWdeFHO8OChQYRJNJvUCqCbrtKA==", "dev": true, "dependencies": { - "browserslist": "^4.22.3" + "browserslist": "^4.23.0" }, "funding": { "type": "opencollective", @@ -6469,6 +6506,9 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -7463,46 +7503,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gettext-parser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-2.0.0.tgz", - "integrity": "sha512-FDs/7XjNw58ToQwJFO7avZZbPecSYgw8PBYhd0An+4JtZSrSzKhEvTsVV2uqdO7VziWTOGSgLGD5YRPdsCjF7Q==", - "dependencies": { - "encoding": "^0.1.12", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/gettext-to-messageformat": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/gettext-to-messageformat/-/gettext-to-messageformat-0.3.1.tgz", - "integrity": "sha512-UyqIL3Ul4NryU95Wome/qtlcuVIqgEWVIFw0zi7Lv14ACLXfaVDCbrjZ7o+3BZ7u+4NS1mP/2O1eXZoHCoas8g==", - "dependencies": { - "gettext-parser": "^1.4.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gettext-to-messageformat/node_modules/gettext-parser": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-1.4.0.tgz", - "integrity": "sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA==", - "dependencies": { - "encoding": "^0.1.12", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/gettext.js": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/gettext.js/-/gettext.js-2.0.2.tgz", - "integrity": "sha512-V5S0HCgOzHmTaaHUNMQr3xVPu6qLpv3j6K9Ygi41Pu6SRNNmWwvXCf/wQYXArznm4uZyZ1v8yrDZngxy6+kCyw==", - "dependencies": { - "po2json": "^1.0.0-beta-3" - }, - "bin": { - "po2json-gettextjs": "bin/po2json" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -10775,6 +10775,18 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -11688,35 +11700,6 @@ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" }, - "node_modules/po2json": { - "version": "1.0.0-beta-3", - "resolved": "https://registry.npmjs.org/po2json/-/po2json-1.0.0-beta-3.tgz", - "integrity": "sha512-taS8y6ZEGzPAs0rygW9CuUPY8C3Zgx6cBy31QXxG2JlWS3fLxj/kuD3cbIfXBg30PuYN7J5oyBa/TIRjyqFFtg==", - "dependencies": { - "commander": "^6.0.0", - "gettext-parser": "2.0.0", - "gettext-to-messageformat": "0.3.1" - }, - "bin": { - "po2json": "bin/po2json" - }, - "engines": { - "node": ">=10.0" - }, - "peerDependencies": { - "commander": "^6.0.0", - "gettext-parser": "2.0.0", - "gettext-to-messageformat": "0.3.1" - } - }, - "node_modules/po2json/node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "engines": { - "node": ">= 6" - } - }, "node_modules/popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -15226,26 +15209,26 @@ } }, "node_modules/webpack": { - "version": "5.90.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.3.tgz", - "integrity": "sha512-h6uDYlWCctQRuXBs1oYpVe6sFcWedl0dpcVaTf/YF67J9bKvwJajFulMVSYKHrksMB3I/pIagRzDxwxkebuzKA==", + "version": "5.91.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.91.0.tgz", + "integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==", "dev": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", "acorn-import-assertions": "^1.9.0", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.16.0", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", @@ -15253,7 +15236,7 @@ "schema-utils": "^3.2.0", "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.0", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { @@ -15344,6 +15327,38 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/webpack-localize-assets-plugin": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/webpack-localize-assets-plugin/-/webpack-localize-assets-plugin-1.5.4.tgz", + "integrity": "sha512-3+iBEnDKm5d1EweaVpFv57lA03ZNFtufBnjwZH8Bz27lT3l7dbANd+i0adSA9G87NzeIo487kk/0SutUqI9x8w==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "acorn": "^8.8.2", + "astring": "^1.8.4", + "magic-string": "^0.27.0", + "webpack-sources": "^2.2.0" + }, + "funding": { + "url": "https://github.com/privatenumber/webpack-localize-assets-plugin?sponsor=1" + }, + "peerDependencies": { + "webpack": "^4.42.0 || ^5.10.0" + } + }, + "node_modules/webpack-localize-assets-plugin/node_modules/webpack-sources": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", + "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "dev": true, + "dependencies": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/webpack-manifest-plugin": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-5.0.0.tgz", @@ -15769,6 +15784,17 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index ff9b0123ce15..bd222a9c1b27 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "cookie": "^0.5.0", "date-fns": "^2.30.0", "debounce": "^1.2.1", - "gettext.js": "^2.0.2", "jquery": "^3.7.0", "normalize.css": "^8.0.1", "stimulus-autocomplete": "^3.1.0" @@ -65,6 +64,7 @@ "webpack": "^5.80.0", "webpack-cli": "^5.0.2", "webpack-livereload-plugin": "^3.0.2", + "webpack-localize-assets-plugin": "^1.5.4", "webpack-manifest-plugin": "^5.0.0", "webpack-remove-empty-scripts": "^1.0.3" }, diff --git a/requirements/dev.txt b/requirements/dev.txt index b9b34ac968f8..d44fe70a699e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -3,4 +3,5 @@ hupper>=1.9 pip-tools>=1.0 pyramid_debugtoolbar>=2.5 pip-api -repository-service-tuf \ No newline at end of file +repository-service-tuf +polib diff --git a/tests/frontend/clipboard_controller_test.js b/tests/frontend/clipboard_controller_test.js index 10f866e7012a..59565da9cce5 100644 --- a/tests/frontend/clipboard_controller_test.js +++ b/tests/frontend/clipboard_controller_test.js @@ -15,7 +15,6 @@ import { Application } from "@hotwired/stimulus"; import ClipboardController from "../../warehouse/static/js/warehouse/controllers/clipboard_controller"; -import {delay} from "./utils"; // Create a mock clipboard object, since jsdom doesn't support the clipboard API // See https://github.com/jsdom/jsdom/issues/1568 @@ -47,14 +46,10 @@ describe("ClipboardController", () => { document.body.innerHTML = clipboardContent; const application = Application.start(); application.register("clipboard", ClipboardController); - - fetch.resetMocks(); }); describe("copy", () => { - it("copies text to clipboard and resets", async () => { - fetch.mockResponseOnce(JSON.stringify({"msg": "Copied"})); - + it("copies text to clipboard and resets", () => { const button = document.querySelector(".copy-tooltip"); expect(button.dataset.clipboardTooltipValue).toEqual("Copy to clipboard"); @@ -63,16 +58,12 @@ describe("ClipboardController", () => { // Check that the text was copied to the clipboard expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Copyable Thing"); // Check that the tooltip text was changed - await delay(25); expect(button.dataset.clipboardTooltipValue).toEqual("Copied"); button.dispatchEvent(new FocusEvent("focusout")); // Check that the tooltip text was reset expect(button.dataset.clipboardTooltipValue).toEqual("Copy to clipboard"); - - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Copied"); }); }); }); diff --git a/tests/frontend/password_match_controller_test.js b/tests/frontend/password_match_controller_test.js index 5675ffec8492..322f9ebe9637 100644 --- a/tests/frontend/password_match_controller_test.js +++ b/tests/frontend/password_match_controller_test.js @@ -16,7 +16,6 @@ import { getByPlaceholderText, fireEvent } from "@testing-library/dom"; import { Application } from "@hotwired/stimulus"; import PasswordMatchController from "../../warehouse/static/js/warehouse/controllers/password_match_controller"; -import { delay } from "./utils"; describe("Password match controller", () => { beforeEach(() => { @@ -31,8 +30,6 @@ describe("Password match controller", () => { const application = Application.start(); application.register("password-match", PasswordMatchController); - - fetch.resetMocks(); }); describe("initial state", function() { @@ -72,64 +69,32 @@ describe("Password match controller", () => { }); describe("adding different text on each field", function() { - it("shows text warning of mismatch and disables submit", async function() { - fetch.mockResponses( - [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], - [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], - [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], - [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], - ); - + it("shows text warning of mismatch and disables submit", function() { fireEvent.input(getByPlaceholderText(document.body, "Your password"), { target: { value: "foo" } }); fireEvent.input(getByPlaceholderText(document.body, "Confirm password"), { target: { value: "bar" } }); - await delay(25); - const message = document.getElementsByTagName("p")[0]; expect(message).toHaveTextContent("Passwords do not match"); expect(message).not.toHaveClass("hidden"); expect(message).not.toHaveClass("form-error--valid"); const submit = document.getElementsByTagName("input")[2]; expect(submit).toHaveAttribute("disabled", ""); - - expect(fetch.mock.calls.length).toEqual(4); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Passwords+do+not+match"); - expect(fetch.mock.calls[1][0]).toEqual("/translation/?s=Passwords+do+not+match"); - expect(fetch.mock.calls[2][0]).toEqual("/translation/?s=Passwords+do+not+match"); - expect(fetch.mock.calls[3][0]).toEqual("/translation/?s=Passwords+do+not+match"); }); }); }); describe("correct inputs", function() { describe("adding the same text on each field", function() { - it("shows success text and enables submit", async function() { - fetch.mockResponses( - [JSON.stringify({"msg": "Passwords do not match"}),{ status: 200 }], - [JSON.stringify({"msg": "Passwords match"}),{ status: 200 }], - [JSON.stringify({"msg": "Passwords match"}),{ status: 200 }], - [JSON.stringify({"msg": "Passwords match"}),{ status: 200 }], - [JSON.stringify({"msg": "Passwords match"}),{ status: 200 }], - ); - + it("shows success text and enables submit", function() { fireEvent.input(getByPlaceholderText(document.body, "Your password"), { target: { value: "foo" } }); fireEvent.input(getByPlaceholderText(document.body, "Confirm password"), { target: { value: "foo" } }); - await delay(25); - const message = document.getElementsByTagName("p")[0]; expect(message).toHaveTextContent("Passwords match"); expect(message).not.toHaveClass("hidden"); expect(message).toHaveClass("form-error--valid"); const submit = document.getElementsByTagName("input")[2]; expect(submit).not.toHaveAttribute("disabled"); - - expect(fetch.mock.calls.length).toEqual(5); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Passwords+match"); - expect(fetch.mock.calls[1][0]).toEqual("/translation/?s=Passwords+match"); - expect(fetch.mock.calls[2][0]).toEqual("/translation/?s=Passwords+match"); - expect(fetch.mock.calls[3][0]).toEqual("/translation/?s=Passwords+match"); - expect(fetch.mock.calls[4][0]).toEqual("/translation/?s=Passwords+match"); }); }); diff --git a/tests/frontend/password_strength_gauge_controller_test.js b/tests/frontend/password_strength_gauge_controller_test.js index 99f81b90552f..8f9fb1095dfb 100644 --- a/tests/frontend/password_strength_gauge_controller_test.js +++ b/tests/frontend/password_strength_gauge_controller_test.js @@ -16,7 +16,6 @@ import { getByPlaceholderText, fireEvent } from "@testing-library/dom"; import { Application } from "@hotwired/stimulus"; import PasswordStrengthGaugeController from "../../warehouse/static/js/warehouse/controllers/password_strength_gauge_controller"; -import {delay} from "./utils"; describe("Password strength gauge controller", () => { beforeEach(() => { @@ -36,17 +35,11 @@ describe("Password strength gauge controller", () => { const application = Application.start(); application.register("password-strength-gauge", PasswordStrengthGaugeController); - - fetch.resetMocks(); }); - describe("initial state", () => { describe("the password strength gauge and screen reader text", () => { it("are at 0 level and reading a password empty text", async () => { - fetch.mockResponses( - [JSON.stringify({"msg": "Password field is empty"}),{ status: 200 }], - ); const passwordTarget = getByPlaceholderText(document.body, "Your password"); fireEvent.input(passwordTarget, { target: { value: "" } }); @@ -58,9 +51,6 @@ describe("Password strength gauge controller", () => { ); expect(gauge).not.toHaveAttribute("data-zxcvbn-score"); expect(gauge.querySelector(".sr-only")).toHaveTextContent("Password field is empty"); - - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Password+field+is+empty"); }); }); }); @@ -76,8 +66,6 @@ describe("Password strength gauge controller", () => { }, }; }); - - const passwordTarget = getByPlaceholderText(document.body, "Your password"); fireEvent.input(passwordTarget, { target: { value: "foo" } }); @@ -99,26 +87,13 @@ describe("Password strength gauge controller", () => { }; }); - fetch.mockResponses( - [JSON.stringify({"msg": "Password is strong"}),{ status: 200 }], - [JSON.stringify({"msg": "Password is strong"}),{ status: 200 }], - [JSON.stringify({"msg": "Password is strong"}),{ status: 200 }], - ); - const passwordTarget = getByPlaceholderText(document.body, "Your password"); fireEvent.input(passwordTarget, { target: { value: "the strongest password ever" } }); - await delay(25); - const gauge = document.getElementById("gauge"); expect(gauge).toHaveClass("password-strength__gauge--5"); expect(gauge).toHaveAttribute("data-zxcvbn-score", "5"); expect(gauge.querySelector(".sr-only")).toHaveTextContent("Password is strong"); - - expect(fetch.mock.calls.length).toEqual(3); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Password+is+strong"); - expect(fetch.mock.calls[1][0]).toEqual("/translation/?s=Password+is+strong"); - expect(fetch.mock.calls[2][0]).toEqual("/translation/?s=Password+is+strong"); }); }); }); diff --git a/tests/frontend/timeago_test.js b/tests/frontend/timeago_test.js index 763b22320183..abb665201417 100644 --- a/tests/frontend/timeago_test.js +++ b/tests/frontend/timeago_test.js @@ -19,8 +19,6 @@ import {delay} from "./utils"; describe("time ago util", () => { beforeEach(() => { document.documentElement.lang = "en"; - - fetch.resetMocks(); }); it("shows 'just now' for a very recent time'", async () => { @@ -28,10 +26,6 @@ describe("time ago util", () => { `; - fetch.mockResponses( - [JSON.stringify({"msg": "Just now"}), {status: 200}], - ); - timeAgo(); await delay(25); @@ -39,8 +33,6 @@ describe("time ago util", () => { const element = document.getElementById("element"); expect(element.innerText).toEqual("Just now"); - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Just+now"); }); it("shows 'About 5 hours ago' for such a time'", async () => { @@ -51,19 +43,12 @@ describe("time ago util", () => { `; - fetch.mockResponses( - [JSON.stringify({"msg": "About 5 hours ago"}), {status: 200}], - ); - timeAgo(); await delay(25); const element = document.getElementById("element"); expect(element.innerText).toEqual("About 5 hours ago"); - - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=About+an+hour+ago&p=About+%24%7BnumHours%7D+hours+ago&n=5&numHours=5"); }); it("shows 'About 36 minutes ago' for such a time'", async () => { @@ -74,19 +59,12 @@ describe("time ago util", () => { `; - fetch.mockResponses( - [JSON.stringify({"msg": "About 36 minutes ago"}), {status: 200}], - ); - timeAgo(); await delay(25); const element = document.getElementById("element"); expect(element.innerText).toEqual("About 36 minutes ago"); - - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=About+a+minute+ago&p=About+%24%7BnumMinutes%7D+minutes+ago&n=36&numMinutes=36"); }); it("shows provided text for Yesterday'", async () => { @@ -97,22 +75,15 @@ describe("time ago util", () => { `; - fetch.mockResponses( - [JSON.stringify({"msg": "one day ago"}), {status: 200}], - ); - timeAgo(); await delay(25); const element = document.getElementById("element"); - expect(element.innerText).toEqual("one day ago"); - - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual("/translation/?s=Yesterday&p=About+%24%7BnumDays%7D+days+ago&n=1&numDays=1"); + expect(element.innerText).toEqual("Yesterday"); }); - it("makes no fetch call when not isBeforeCutoff", async () => { + it("makes no call when not isBeforeCutoff", async () => { document.body.innerHTML = ` `; @@ -123,7 +94,5 @@ describe("time ago util", () => { const element = document.getElementById("element"); expect(element.textContent).toEqual("existing text"); - - expect(fetch.mock.calls.length).toEqual(0); }); }); diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 5bf4e1a728a6..9deefa9cec69 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -77,7 +77,6 @@ def add_policy(name, filename): pretend.call("force-status", r"/_force-status/{status:[45]\d\d}/"), pretend.call("index", "/", domain=warehouse), pretend.call("locale", "/locale/", domain=warehouse), - pretend.call("translation", "/translation/", domain=warehouse), pretend.call("robots.txt", "/robots.txt", domain=warehouse), pretend.call("opensearch.xml", "/opensearch.xml", domain=warehouse), pretend.call("index.sitemap.xml", "/sitemap.xml", domain=warehouse), diff --git a/tests/unit/test_views.py b/tests/unit/test_views.py index 536caa9ae7ff..93138e09e789 100644 --- a/tests/unit/test_views.py +++ b/tests/unit/test_views.py @@ -11,7 +11,6 @@ # limitations under the License. import datetime -import json import elasticsearch import pretend @@ -51,7 +50,6 @@ session_notifications, sidebar_sponsor_logo, stats, - translation, ) from ..common.db.accounts import UserFactory @@ -438,129 +436,6 @@ def test_locale_bad_request(self, get, monkeypatch): locale(request) -class TestTranslation: - @pytest.mark.parametrize( - ("referer", "redirect", "params"), - [ - (None, "/fake-route", MultiDict({"s": "Test text string"})), - ("/robots.txt", "/robots.txt", MultiDict({"s": "Test text string"})), - ], - ) - def test_translation_singular(self, referer, redirect, params): - def _translate(tstring, domain, mapping): - return json.dumps( - { - "tstring": tstring, - "domain": "messages", - "val": "test value", - } - ) - - localizer = pretend.stub(translate=_translate) - request = pretend.stub( - params=params, - referer=referer, - route_path=pretend.call_recorder(lambda r: "/fake-route"), - session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), - host=None, - localizer=localizer, - ) - result = translation(request) - assert result == { - "msg": json.dumps( - { - "tstring": "Test text string", - "domain": "messages", - "val": "test value", - } - ) - } - - @pytest.mark.parametrize( - ("referer", "redirect", "params"), - [ - ( - None, - "/fake-route", - MultiDict( - { - "s": "Test text string ${va}", - "p": "Test text strings ${val}", - "n": 3, - "val": "test value", - } - ), - ), - ( - "/robots.txt", - "/robots.txt", - MultiDict( - { - "s": "Test text string ${va}", - "p": "Test text strings ${val}", - "n": 3, - "val": "test value", - } - ), - ), - ], - ) - def test_translation_plural(self, referer, redirect, params): - def _pluralize(singular, plural, n, domain, mapping): - return json.dumps( - { - "singular": singular, - "plural": plural, - "n": n, - "domain": "messages", - "val": "test value", - } - ) - - localizer = pretend.stub(pluralize=_pluralize) - request = pretend.stub( - params=params, - referer=referer, - route_path=pretend.call_recorder(lambda r: "/fake-route"), - session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), - host=None, - localizer=localizer, - ) - result = translation(request) - assert result == { - "msg": json.dumps( - { - "singular": "Test text string ${va}", - "plural": "Test text strings ${val}", - "n": 3, - "domain": "messages", - "val": "test value", - } - ) - } - - @pytest.mark.parametrize( - "params", - [ - MultiDict({"nonsense": "arguments"}), - MultiDict({"n": "2"}), - MultiDict({"n": "2", "p": "plural strings ${val}", "val": "the value"}), - ], - ) - def test_translation_bad_request(self, params): - request = pretend.stub( - params=params, - route_path=pretend.call_recorder(lambda r: "/fake-route"), - session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), - host=None, - ) - - with pytest.raises( - HTTPBadRequest, match="Message singular must be provided as 's'." - ): - translation(request) - - def test_csi_current_user_indicator(): assert current_user_indicator(pretend.stub()) == {} diff --git a/warehouse/admin/static/js/warehouse.js b/warehouse/admin/static/js/warehouse.js index e43df661b0c0..12bed42538aa 100644 --- a/warehouse/admin/static/js/warehouse.js +++ b/warehouse/admin/static/js/warehouse.js @@ -28,7 +28,7 @@ import "admin-lte/plugins/datatables-rowgroup/js/rowGroup.bootstrap4"; // Import AdminLTE JS import "admin-lte/build/js/AdminLTE"; -import { gettext } from "warehouse/utils/fetch-gettext"; +import {gettext} from "warehouse/utils/messages-access"; document.querySelectorAll("a[data-form-submit]").forEach(function (element) { @@ -104,9 +104,8 @@ document.querySelectorAll(".copy-text").forEach(function (element) { $("#copied_tip").remove(); }, 1000); - gettext("Copied").then((text) => { - $(target).append(`
${text}
`); - }); + const copiedText = gettext("Copied"); + $(target).append(`
${copiedText}
`); navigator.clipboard.writeText(text); } diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html index 250244c68fd2..b617b447eb7a 100644 --- a/warehouse/admin/templates/admin/base.html +++ b/warehouse/admin/templates/admin/base.html @@ -267,7 +267,7 @@

{{ self.title()|default('Dashboard', true) }}

- + diff --git a/warehouse/i18n/__init__.py b/warehouse/i18n/__init__.py index e35e0288c424..a16d5513441c 100644 --- a/warehouse/i18n/__init__.py +++ b/warehouse/i18n/__init__.py @@ -28,8 +28,8 @@ "es", # Spanish "fr", # French "ja", # Japanese - "pt_BR", # Brazilian Portugeuse - "uk", # Ukranian + "pt_BR", # Brazilian Portuguese + "uk", # Ukrainian "el", # Greek "de", # German "zh_Hans", # Simplified Chinese diff --git a/warehouse/locale/de/LC_MESSAGES/messages.json b/warehouse/locale/de/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..3c89063cb0db --- /dev/null +++ b/warehouse/locale/de/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=2; plural=n != 1;", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/el/LC_MESSAGES/messages.json b/warehouse/locale/el/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..3c89063cb0db --- /dev/null +++ b/warehouse/locale/el/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=2; plural=n != 1;", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/eo/LC_MESSAGES/messages.json b/warehouse/locale/eo/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..3c89063cb0db --- /dev/null +++ b/warehouse/locale/eo/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=2; plural=n != 1;", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/es/LC_MESSAGES/messages.json b/warehouse/locale/es/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..3c89063cb0db --- /dev/null +++ b/warehouse/locale/es/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=2; plural=n != 1;", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/fr/LC_MESSAGES/messages.json b/warehouse/locale/fr/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..447a4e48b1ac --- /dev/null +++ b/warehouse/locale/fr/LC_MESSAGES/messages.json @@ -0,0 +1,110 @@ +{ + "plural-forms": "nplurals=2; plural=n > 1;", + "entries": [ + { + "flags": [], + "msgctxt": null, + "msgid": "Error while validating hashed password, disregard on development", + "msgid_plural": "", + "msgid_with_context": "Error while validating hashed password, disregard on development", + "msgstr": "FR: Error while validating hashed password, disregard on development", + "msgstr_plural": {}, + "prev_msgctxt": null, + "prev_msgid": null, + "prev_msgid_plural": null + }, + { + "flags": [], + "msgctxt": null, + "msgid": "Passwords match", + "msgid_plural": "", + "msgid_with_context": "Passwords match", + "msgstr": "FR: Passwords match", + "msgstr_plural": {}, + "prev_msgctxt": null, + "prev_msgid": null, + "prev_msgid_plural": null + }, + { + "flags": [], + "msgctxt": null, + "msgid": "Passwords do not match", + "msgid_plural": "", + "msgid_with_context": "Passwords do not match", + "msgstr": "FR: Passwords do not match", + "msgstr_plural": {}, + "prev_msgctxt": null, + "prev_msgid": null, + "prev_msgid_plural": null + }, + { + "flags": [], + "msgctxt": null, + "msgid": "Password field is empty", + "msgid_plural": "", + "msgid_with_context": "Password field is empty", + "msgstr": "FR: Password field is empty", + "msgstr_plural": {}, + "prev_msgctxt": null, + "prev_msgid": null, + "prev_msgid_plural": null + }, + { + "flags": [], + "msgctxt": null, + "msgid": "Password is strong", + "msgid_plural": "", + "msgid_with_context": "Password is strong", + "msgstr": "FR: Password is strong", + "msgstr_plural": {}, + "prev_msgctxt": null, + "prev_msgid": null, + "prev_msgid_plural": null + }, + { + "flags": [], + "msgctxt": null, + "msgid": "Yesterday", + "msgid_plural": "About %1 days ago", + "msgid_with_context": "Yesterday", + "msgstr": "", + "msgstr_plural": { + "0": "FR: Yesterday", + "1": "FR: About %1 days ago" + }, + "prev_msgctxt": null, + "prev_msgid": null, + "prev_msgid_plural": null + }, + { + "flags": [], + "msgctxt": null, + "msgid": "About an hour ago", + "msgid_plural": "About %1 hours ago", + "msgid_with_context": "About an hour ago", + "msgstr": "", + "msgstr_plural": { + "0": "FR: About an hour ago", + "1": "FR: About %1 hours ago" + }, + "prev_msgctxt": null, + "prev_msgid": null, + "prev_msgid_plural": null + }, + { + "flags": [], + "msgctxt": null, + "msgid": "About a minute ago", + "msgid_plural": "About %1 minutes ago", + "msgid_with_context": "About a minute ago", + "msgstr": "", + "msgstr_plural": { + "0": "FR: About a minute ago", + "1": "FR: About %1 minutes ago" + }, + "prev_msgctxt": null, + "prev_msgid": null, + "prev_msgid_plural": null + } + ] +} diff --git a/warehouse/locale/fr/LC_MESSAGES/messages.po b/warehouse/locale/fr/LC_MESSAGES/messages.po index 2887df6ffeff..d4d5758b7092 100644 --- a/warehouse/locale/fr/LC_MESSAGES/messages.po +++ b/warehouse/locale/fr/LC_MESSAGES/messages.po @@ -12165,3 +12165,46 @@ msgstr[1] "Aucun résultat pour les filtres « %(filters)s »" #~ msgid "%(num_users)s users" #~ msgstr "%(num_users)s utilisateurs" + +#: warehouse/static/js/warehouse/controllers/password_breach_controller.js:48 +msgid "Error while validating hashed password, disregard on development" +msgstr "FR: Error while validating hashed password, disregard on development" + +#: warehouse/static/js/warehouse/controllers/password_match_controller.js:32 +msgid "Passwords match" +msgstr "FR: Passwords match" + +#: warehouse/static/js/warehouse/controllers/password_match_controller.js:38 +msgid "Passwords do not match" +msgstr "FR: Passwords do not match" + +#: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:27 +#: warehouse/templates/base.html:30 +msgid "Password field is empty" +msgstr "FR: Password field is empty" + +#: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:43 +msgid "Password is strong" +msgstr "FR: Password is strong" + +#: warehouse/static/js/warehouse/utils/timeago.js:33 +msgid "Yesterday" +msgid_plural "About %1 days ago" +msgstr[0] "FR: Yesterday" +msgstr[1] "FR: About %1 days ago" + +#: warehouse/static/js/warehouse/utils/timeago.js:37 +msgid "About an hour ago" +msgid_plural "About %1 hours ago" +msgstr[0] "FR: About an hour ago" +msgstr[1] "FR: About %1 hours ago" + +#: warehouse/static/js/warehouse/utils/timeago.js:39 +msgid "About a minute ago" +msgid_plural "About %1 minutes ago" +msgstr[0] "FR: About a minute ago" +msgstr[1] "FR: About %1 minutes ago" + +#: warehouse/static/js/warehouse/utils/timeago.js:41 +msgid "Just now" +msgstr "" diff --git a/warehouse/locale/he/LC_MESSAGES/messages.json b/warehouse/locale/he/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..05c496c5b96d --- /dev/null +++ b/warehouse/locale/he/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3));", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/ja/LC_MESSAGES/messages.json b/warehouse/locale/ja/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..78afcb065eb1 --- /dev/null +++ b/warehouse/locale/ja/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=1; plural=0;", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 3da0d198195f..733903b79aa5 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -722,7 +722,7 @@ msgstr "" msgid "Passwords match" msgstr "" -#: warehouse/static/js/warehouse/controllers/password_match_controller.js:38 +#: warehouse/static/js/warehouse/controllers/password_match_controller.js:36 msgid "Passwords do not match" msgstr "" @@ -731,25 +731,25 @@ msgstr "" msgid "Password field is empty" msgstr "" -#: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:43 +#: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:42 msgid "Password is strong" msgstr "" #: warehouse/static/js/warehouse/utils/timeago.js:33 msgid "Yesterday" -msgid_plural "About %1 days ago" +msgid_plural "About ${numDays} days ago" msgstr[0] "" msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:37 msgid "About an hour ago" -msgid_plural "About %1 hours ago" +msgid_plural "About ${numHours} hours ago" msgstr[0] "" msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:39 msgid "About a minute ago" -msgid_plural "About %1 minutes ago" +msgid_plural "About ${numMinutes} minutes ago" msgstr[0] "" msgstr[1] "" diff --git a/warehouse/locale/pt_BR/LC_MESSAGES/messages.json b/warehouse/locale/pt_BR/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..3c7d040a2b46 --- /dev/null +++ b/warehouse/locale/pt_BR/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=2; plural=n > 1;", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/ru/LC_MESSAGES/messages.json b/warehouse/locale/ru/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..0b95087acbc7 --- /dev/null +++ b/warehouse/locale/ru/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/uk/LC_MESSAGES/messages.json b/warehouse/locale/uk/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..0b95087acbc7 --- /dev/null +++ b/warehouse/locale/uk/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json b/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..78afcb065eb1 --- /dev/null +++ b/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=1; plural=0;", "entries": []} \ No newline at end of file diff --git a/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json b/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json new file mode 100644 index 000000000000..78afcb065eb1 --- /dev/null +++ b/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json @@ -0,0 +1 @@ +{"plural-forms": "nplurals=1; plural=0;", "entries": []} \ No newline at end of file diff --git a/warehouse/routes.py b/warehouse/routes.py index 34bcf7641c6c..e260ab11ba86 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -28,7 +28,6 @@ def includeme(config): # Basic global routes config.add_route("index", "/", domain=warehouse) config.add_route("locale", "/locale/", domain=warehouse) - config.add_route("translation", "/translation/", domain=warehouse) config.add_route("robots.txt", "/robots.txt", domain=warehouse) config.add_route("opensearch.xml", "/opensearch.xml", domain=warehouse) config.add_route("index.sitemap.xml", "/sitemap.xml", domain=warehouse) diff --git a/warehouse/static/js/warehouse/controllers/clipboard_controller.js b/warehouse/static/js/warehouse/controllers/clipboard_controller.js index 4f754da60c10..22e47ae41d3a 100644 --- a/warehouse/static/js/warehouse/controllers/clipboard_controller.js +++ b/warehouse/static/js/warehouse/controllers/clipboard_controller.js @@ -12,7 +12,7 @@ * limitations under the License. */ import { Controller } from "@hotwired/stimulus"; -import { gettext } from "../utils/fetch-gettext"; +import { gettext } from "../utils/messages-access"; // Copy handler for copy tooltips, e.g. // - the pip command on package detail page @@ -28,10 +28,8 @@ export default class extends Controller { const clipboardTooltipOriginalValue = this.tooltipTarget.dataset.clipboardTooltipValue; // copy the source text to clipboard navigator.clipboard.writeText(this.sourceTarget.textContent); - // set the tooltip text to "Copied" - gettext("Copied").then((text) => { - this.tooltipTarget.dataset.clipboardTooltipValue = text; - }); + // set the tooltip text + this.tooltipTarget.dataset.clipboardTooltipValue = gettext("Copied"); // on focusout and mouseout, reset the tooltip text to the original value const resetTooltip = () => { diff --git a/warehouse/static/js/warehouse/controllers/password_breach_controller.js b/warehouse/static/js/warehouse/controllers/password_breach_controller.js index 360f3669f0a8..e0adb23e53a2 100644 --- a/warehouse/static/js/warehouse/controllers/password_breach_controller.js +++ b/warehouse/static/js/warehouse/controllers/password_breach_controller.js @@ -14,7 +14,7 @@ import { Controller } from "@hotwired/stimulus"; import { debounce } from "debounce"; -import { gettext } from "../utils/fetch-gettext"; +import { gettext } from "../utils/messages-access"; export default class extends Controller { static targets = ["password", "message"]; @@ -45,9 +45,8 @@ export default class extends Controller { let hex = this.hexString(digest); let response = await fetch(this.getURL(hex)); if (response.ok === false) { - gettext("Error while validating hashed password, disregard on development").then((text) => { - console.error(`${text}: ${response.status} ${response.statusText}`); // eslint-disable-line no-console - }); + const msgText = gettext("Error while validating hashed password, disregard on development"); + console.error(`${msgText}: ${response.status} ${response.statusText}`); // eslint-disable-line no-console } else { let text = await response.text(); this.parseResponse(text, hex); diff --git a/warehouse/static/js/warehouse/controllers/password_match_controller.js b/warehouse/static/js/warehouse/controllers/password_match_controller.js index 2b525436e35a..7f540f4ebd54 100644 --- a/warehouse/static/js/warehouse/controllers/password_match_controller.js +++ b/warehouse/static/js/warehouse/controllers/password_match_controller.js @@ -13,7 +13,7 @@ */ import { Controller } from "@hotwired/stimulus"; -import { gettext } from "../utils/fetch-gettext"; +import { gettext } from "../utils/messages-access"; export default class extends Controller { static targets = ["passwordMatch", "matchMessage", "submit"]; @@ -29,15 +29,11 @@ export default class extends Controller { } else { this.matchMessageTarget.classList.remove("hidden"); if (this.passwordMatchTargets.every((field, i, arr) => field.value === arr[0].value)) { - gettext("Passwords match").then((text) => { - this.matchMessageTarget.textContent = text; - }); + this.matchMessageTarget.textContent = gettext("Passwords match"); this.matchMessageTarget.classList.add("form-error--valid"); this.submitTarget.removeAttribute("disabled"); } else { - gettext("Passwords do not match").then((text) => { - this.matchMessageTarget.textContent = text; - }); + this.matchMessageTarget.textContent = gettext("Passwords do not match"); this.matchMessageTarget.classList.remove("form-error--valid"); this.submitTarget.setAttribute("disabled", ""); } diff --git a/warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js b/warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js index e58560d379ba..b7b1d60594f2 100644 --- a/warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js +++ b/warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js @@ -15,7 +15,7 @@ /* global zxcvbn */ import { Controller } from "@hotwired/stimulus"; -import { gettext } from "../utils/fetch-gettext"; +import { gettext } from "../utils/messages-access"; export default class extends Controller { static targets = ["password", "strengthGauge"]; @@ -24,9 +24,7 @@ export default class extends Controller { let password = this.passwordTarget.value; if (password === "") { this.strengthGaugeTarget.setAttribute("class", "password-strength__gauge"); - gettext("Password field is empty").then((text) => { - this.setScreenReaderMessage(text); - }); + this.setScreenReaderMessage(gettext("Password field is empty")); } else { // following recommendations on the zxcvbn JS docs // the zxcvbn function is available by loading `vendor/zxcvbn.js` @@ -35,14 +33,13 @@ export default class extends Controller { this.strengthGaugeTarget.setAttribute("class", `password-strength__gauge password-strength__gauge--${zxcvbnResult.score}`); this.strengthGaugeTarget.setAttribute("data-zxcvbn-score", zxcvbnResult.score); - // TODO: how to translate zxcvbn feedback suggestion strings? const feedbackSuggestions = zxcvbnResult.feedback.suggestions.join(" "); if (feedbackSuggestions) { - this.setScreenReaderMessage(feedbackSuggestions); + // Note: we can't localize this string because it will be mixed + // with other non-localizable strings from zxcvbn + this.setScreenReaderMessage("Password is too easily guessed. " + feedbackSuggestions); } else { - gettext("Password is strong").then((text) => { - this.setScreenReaderMessage(text); - }); + this.setScreenReaderMessage(gettext("Password is strong")); } } } diff --git a/warehouse/static/js/warehouse/utils/messages-access.js b/warehouse/static/js/warehouse/utils/messages-access.js index de1627094e80..76d20771eae3 100644 --- a/warehouse/static/js/warehouse/utils/messages-access.js +++ b/warehouse/static/js/warehouse/utils/messages-access.js @@ -11,26 +11,15 @@ * limitations under the License. */ -const i18n = require("gettext.js"); -const messages = require("./messages.json"); - -function determineLocale() { - // check cookie - const locale = document.cookie - .split("; ") - .find((row) => row.startsWith("_LOCALE_=")) - ?.split("=")[1]; - return locale ?? "en"; -} /** * Get the translation using num to choose the appropriate string. * * When importing this function, it must be named in a particular way to be recognised by babel * and have the translation strings extracted correctly. - * Function 'gettext' for singular only extraction, function ngettext for singular and plural extraction. + * Function 'ngettext' for plural extraction. * - * Any placeholders must be specified as '%1', '%2', etc. + * Any placeholders must be specified as * * @example * import { gettext, ngettext } from "warehouse/utils/messages-access"; @@ -42,33 +31,38 @@ function determineLocale() { * @param singular {string} The default string for the singular translation. * @param plural {string} The default string for the plural translation. * @param num {number} The number to use to select the appropriate translation. - * @param values {array[string]} Additional values to fill the placeholders. - * @returns {Promise} The promise. + * @returns {string} The translated text. * @see https://www.gnu.org/software/gettext/manual/gettext.html#Language-specific-options * @see https://docs.pylonsproject.org/projects/pyramid/en/latest/api/i18n.html#pyramid.i18n.Localizer.pluralize */ -export function ngettext(singular, plural, num, ...values) { - const locale = determineLocale(); - const json = messages.find((element) => element[""].language === locale); - if (json) { - i18n.loadJSON(json, "messages"); - } - return Promise.resolve(i18n.ngettext(singular, plural, num, num, ...values)); +export function ngettext(singular, plural, num) { + return JSON.stringify({singular: singular, plural: plural, num: num}); } /** - * Get the singlar translation. + * Get the singular translation. * * When importing this function, it must be named in a particular way to be recognised by babel * and have the translation strings extracted correctly. - * Function 'gettext' for singular only extraction, function ngettext for singular and plural extraction. + * Function 'gettext' for singular extraction. * - * Any placeholders must be specified as '%1', '%2', etc. + * Any placeholders must be specified as * * @param singular {string} The default string for the singular translation. - * @param values {array[string]} Additional values to fill the placeholders. - * @returns {Promise} The promise. + * @returns {string} The translated text. */ -export function gettext(singular, ...values) { - return Promise.resolve(i18n.gettext(singular, ...values)); +export function gettext(singular) { + return JSON.stringify({singular: singular}); +} + +export function templateMessage(strings, ...keys) { + return (...values) => { + const dict = values[values.length - 1] || {}; + const result = [strings[0]]; + keys.forEach((key, i) => { + const value = Number.isInteger(key) ? values[key] : dict[key]; + result.push(value, strings[i + 1]); + }); + return result.join(""); + }; } diff --git a/warehouse/static/js/warehouse/utils/messages.json b/warehouse/static/js/warehouse/utils/messages.json deleted file mode 100644 index 4b873eb1ef5f..000000000000 --- a/warehouse/static/js/warehouse/utils/messages.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "": { - "language": "am", - "plural-forms": "nplurals=2; plural=n > 1;" - }, - "You must verify your **primary** email address before you can perform this action.": "some translation", - "Copied": "Copied translation" - } -] diff --git a/warehouse/static/js/warehouse/utils/timeago.js b/warehouse/static/js/warehouse/utils/timeago.js index 5e572150aa0e..b85c43fb953e 100644 --- a/warehouse/static/js/warehouse/utils/timeago.js +++ b/warehouse/static/js/warehouse/utils/timeago.js @@ -11,7 +11,7 @@ * limitations under the License. */ -import { gettext, ngettext } from "./messages-access"; +import { gettext, ngettext } from "../utils/messages-access"; const enumerateTime = (timestampString) => { const now = new Date(), @@ -26,17 +26,17 @@ const enumerateTime = (timestampString) => { return time; }; -const convertToReadableText = async (time) => { +const convertToReadableText = (time) => { let { numDays, numMinutes, numHours } = time; if (numDays >= 1) { - return ngettext("Yesterday", "About %1 days ago", numDays, {"numDays": numDays}); + return ngettext("Yesterday", "About ${numDays} days ago", numDays); } if (numHours > 0) { - return ngettext("About an hour ago", "About %1 hours ago", numHours, {"numHours": numHours}); + return ngettext("About an hour ago", "About ${numHours} hours ago", numHours); } else if (numMinutes > 0) { - return ngettext("About a minute ago", "About %1 minutes ago", numMinutes, {"numMinutes": numMinutes}); + return ngettext("About a minute ago", "About ${numMinutes} minutes ago", numMinutes); } else { return gettext("Just now"); } @@ -48,10 +48,7 @@ export default () => { const datetime = timeElement.getAttribute("datetime"); const time = enumerateTime(datetime); if (time.isBeforeCutoff) { - convertToReadableText(time) - .then((text) => { - timeElement.innerText = text; - }); + timeElement.innerText = convertToReadableText(time); } } }; diff --git a/warehouse/templates/base.html b/warehouse/templates/base.html index dd1ee6662dea..6eb35c592ce6 100644 --- a/warehouse/templates/base.html +++ b/warehouse/templates/base.html @@ -131,7 +131,7 @@ {% if request.registry.settings.get("ga4.tracking_id") -%} data-ga4-id="{{ request.registry.settings['ga4.tracking_id'] }}" {% endif %} - src="{{ request.static_path('warehouse:static/dist/js/warehouse.js') }}"> + src="{{ request.static_path('warehouse:static/dist/js/warehouse' + request.localizer.locale_name + '.js') }}"> {% block extra_js %} {% endblock %} diff --git a/warehouse/views.py b/warehouse/views.py index 221caddf3471..ef620fa68e53 100644 --- a/warehouse/views.py +++ b/warehouse/views.py @@ -297,33 +297,6 @@ def locale(request): return resp -@view_config( - route_name="translation", - renderer="json", - request_method="GET", - accept="application/json", - has_translations=True, -) -def translation(request): - singular = request.params.get("s", None) - plural = request.params.get("p", None) - num = request.params.get("n", None) - domain = "messages" - values = request.params.mixed() - - if not singular: - raise HTTPBadRequest("Message singular must be provided as 's'.") - - localizer = request.localizer - localizer_kwargs = {"domain": domain, "mapping": values} - if plural is None or num is None: - msg = localizer.translate(singular, **localizer_kwargs) - else: - msg = localizer.pluralize(singular, plural, int(num), **localizer_kwargs) - - return {"msg": msg} - - @view_config( route_name="classifiers", renderer="pages/classifiers.html", has_translations=True ) diff --git a/webpack.config.js b/webpack.config.js index 6ff06e3da2ad..304f45db211c 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,6 +15,7 @@ // See: https://webpack.js.org/configuration/ const path = require("path"); +const fs = require("fs"); const zlib = require("zlib"); const glob = require("glob"); const rtlcss = require("rtlcss"); @@ -22,11 +23,12 @@ const CompressionPlugin = require("compression-webpack-plugin"); const CopyPlugin = require("copy-webpack-plugin"); const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); -const LiveReloadPlugin = require('webpack-livereload-plugin'); +const LiveReloadPlugin = require("webpack-livereload-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const ProvidePlugin = require("webpack").ProvidePlugin; const RemoveEmptyScriptsPlugin = require("webpack-remove-empty-scripts"); const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); +const LocalizeAssetsPlugin = require("webpack-localize-assets-plugin"); /* Shared Plugins */ @@ -84,6 +86,50 @@ const sharedWebpackManifestPlugins = [ }), ]; +// The translations here must match the KNOWN_LOCALES in warehouse/i18n/__init__.py +// Without 'en', as that is the default language. +const KNOWN_LOCALES = [ + "es", + "fr", + "ja", + "pt_BR", + "uk", + "el", + "de", + "zh_Hans", + "zh_Hant", + "ru", + "he", + "eo", +]; +const translations = {}; +const plural_forms = {}; +KNOWN_LOCALES.forEach((locale) => { + const messages = path.resolve(__dirname, `warehouse/locale/${locale}/LC_MESSAGES/messages.json`); + translations[locale] = messages.entries; + plural_forms[locale] = messages["plural-forms"]; +}); +const sharedTranslationPlugins = [ + new LocalizeAssetsPlugin({ + locales: translations, + sourceMapForLocales: KNOWN_LOCALES, + throwOnMissing: true, + warnOnUnusedString: true, + localizeCompiler: { + gettext(callArguments, localeName) { + const [singular] = callArguments; + const availableForms = this.resolveKey(singular); + return `gettext("${availableForms[0]}")/*${localeName}*/`; + }, + ngettext(callArguments, localeName) { + const [singular, plural, num] = callArguments; + const availableForms = this.resolveKey(singular); + return `ngettext("${availableForms[0]}","${JSON.stringify(availableForms)}",${num})/*${localeName},plural:${plural}*/`; + }, + }, + }), +]; + /* End Shared Plugins */ const sharedResolve = { @@ -95,6 +141,12 @@ const sharedResolve = { module.exports = [ { + stats: { + errors: true, + children: true, + logging: "verbose", + loggingDebug: ["LocalizeAssetsPlugin"], + }, name: "warehouse", experiments: { // allow us to manage RTL CSS as a separate file @@ -115,6 +167,7 @@ module.exports = [ }, ], }), + ...sharedTranslationPlugins, ...sharedCompressionPlugins, ...sharedCSSPlugins, ...sharedWebpackManifestPlugins, @@ -128,7 +181,7 @@ module.exports = [ warehouse: { import: "./warehouse/static/js/warehouse/index.js", // override the filename from `index` to `warehouse` - filename: "js/warehouse.[contenthash].js", + filename: "js/warehouse.[locale].[contenthash].js", }, /* CSS */ @@ -155,7 +208,7 @@ module.exports = [ // Matches current behavior. Defaults to 20. 16 in the future. hashDigestLength: 8, // Global filename template for all assets. Other assets MUST override. - filename: "[name].[contenthash].js", + filename: "[name].[locale].[contenthash].js", // Global output path for all assets. path: path.resolve(__dirname, "warehouse/static/dist"), }, @@ -274,8 +327,15 @@ module.exports = [ }, }, { + stats: { + errors: true, + children: true, + logging: "verbose", + loggingDebug: ["LocalizeAssetsPlugin"], + }, name: "admin", plugins: [ + ...sharedTranslationPlugins, ...sharedCompressionPlugins, ...sharedCSSPlugins, ...sharedWebpackManifestPlugins, @@ -290,7 +350,7 @@ module.exports = [ entry: { admin: { import: "./warehouse/admin/static/js/warehouse.js", - filename: "js/admin.[contenthash].js", + filename: "js/admin.[locale].[contenthash].js", }, all: { import: "./warehouse/admin/static/css/admin.scss", @@ -300,7 +360,7 @@ module.exports = [ output: { clean: true, hashDigestLength: 8, - filename: "[name].[contenthash].js", + filename: "[name].[locale].[contenthash].js", path: path.resolve(__dirname, "warehouse/admin/static/dist"), }, module: { From 4e1667de7def866adcf995d64725872d20bcab4e Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Fri, 19 Apr 2024 23:03:08 +1000 Subject: [PATCH 27/52] check point --- Dockerfile | 1 + bin/translations_json.py | 30 +- docker-compose.yml | 1 + package-lock.json | 132 +++-- package.json | 2 +- warehouse/admin/templates/admin/base.html | 2 +- warehouse/locale/Makefile | 1 + warehouse/locale/de/LC_MESSAGES/messages.json | 2 +- warehouse/locale/el/LC_MESSAGES/messages.json | 2 +- warehouse/locale/eo/LC_MESSAGES/messages.json | 2 +- warehouse/locale/es/LC_MESSAGES/messages.json | 2 +- warehouse/locale/fr/LC_MESSAGES/messages.json | 111 +--- warehouse/locale/he/LC_MESSAGES/messages.json | 2 +- warehouse/locale/ja/LC_MESSAGES/messages.json | 2 +- .../locale/pt_BR/LC_MESSAGES/messages.json | 2 +- warehouse/locale/ru/LC_MESSAGES/messages.json | 2 +- warehouse/locale/uk/LC_MESSAGES/messages.json | 2 +- .../locale/zh_Hans/LC_MESSAGES/messages.json | 2 +- .../locale/zh_Hant/LC_MESSAGES/messages.json | 2 +- .../js/warehouse/utils/messages-access.js | 41 +- webpack.config.js | 552 +++++++++--------- webpack.plugin.localize.js | 154 +++++ 22 files changed, 563 insertions(+), 486 deletions(-) create mode 100644 webpack.plugin.localize.js diff --git a/Dockerfile b/Dockerfile index a8d290a0682b..5e3282351ee3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ COPY warehouse/static/ /opt/warehouse/src/warehouse/static/ COPY warehouse/admin/static/ /opt/warehouse/src/warehouse/admin/static/ COPY warehouse/locale/ /opt/warehouse/src/warehouse/locale/ COPY webpack.config.js /opt/warehouse/src/ +COPY webpack.plugin.localize.js /opt/warehouse/src/ RUN NODE_ENV=production npm run build diff --git a/bin/translations_json.py b/bin/translations_json.py index 89c22c68f6a4..1cf7ef944f1b 100644 --- a/bin/translations_json.py +++ b/bin/translations_json.py @@ -12,32 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -import gettext import json import pathlib import polib +from warehouse.i18n import KNOWN_LOCALES + """ """ domain = "messages" localedir = "warehouse/locale" -languages = [ - "es", - "fr", - "ja", - "pt_BR", - "uk", - "el", - "de", - "zh_Hans", - "zh_Hant", - "ru", - "he", - "eo", -] +languages = [locale for locale in KNOWN_LOCALES] +cwd = pathlib.Path().cwd() +print("\nCreating messages.json files\n") # look in each language file that is used by the app for lang in languages: @@ -45,8 +35,9 @@ entries = [] include_next = False - mo_file = gettext.find(domain, localedir=localedir, languages=[lang]) - po_path = pathlib.Path(mo_file).with_suffix('.po') + po_path = cwd.joinpath(localedir, lang, 'LC_MESSAGES', 'messages.po') + if not po_path.exists(): + continue po = polib.pofile(po_path) for entry in po.translated_entries(): occurs_in_js = any(o.endswith('.js') for o, _ in entry.occurrences) @@ -56,8 +47,9 @@ # if one or more translation messages from javascript files were found, # then write the json file to the same folder. result = { + "locale": lang, "plural-forms": po.metadata['Plural-Forms'], - "entries": {((e.msgid),{ + "entries": {e.msgid: { "flags": e.flags, "msgctxt": e.msgctxt, "msgid": e.msgid, @@ -68,7 +60,7 @@ "prev_msgctxt": e.previous_msgctxt, "prev_msgid": e.previous_msgid, "prev_msgid_plural": e.previous_msgid_plural, - }) for e in entries}, + } for e in entries}, } json_path = po_path.with_suffix('.json') with json_path.open('w') as f: diff --git a/docker-compose.yml b/docker-compose.yml index 3174b31cdecb..c6c104307003 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -209,6 +209,7 @@ services: volumes: - ./warehouse:/opt/warehouse/src/warehouse:z - ./webpack.config.js:/opt/warehouse/src/webpack.config.js:z + - ./webpack.plugin.localize.js:/opt/warehouse/src/webpack.plugin.localize.js:z - ./.babelrc:/opt/warehouse/src/.babelrc:z - ./.stylelintrc.json:/opt/warehouse/src/.stylelintrc.json:z - ./tests/frontend:/opt/warehouse/src/tests/frontend:z diff --git a/package-lock.json b/package-lock.json index 0a850172e144..152933d0be70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "css-loader": "^6.7.3", "css-minimizer-webpack-plugin": "^5.0.0", "eslint": "^8.36.0", + "gettext.js": "^2.0.2", "glob": "^10.2.2", "image-minimizer-webpack-plugin": "^3.8.1", "jest": "^29.5.0", @@ -48,7 +49,6 @@ "webpack": "^5.80.0", "webpack-cli": "^5.0.2", "webpack-livereload-plugin": "^3.0.2", - "webpack-localize-assets-plugin": "^1.5.4", "webpack-manifest-plugin": "^5.0.0", "webpack-remove-empty-scripts": "^1.0.3" } @@ -4295,15 +4295,6 @@ "node": ">=8" } }, - "node_modules/astring": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz", - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "dev": true, - "bin": { - "astring": "bin/astring" - } - }, "node_modules/async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -6507,8 +6498,6 @@ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -7503,6 +7492,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gettext-parser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-2.0.0.tgz", + "integrity": "sha512-FDs/7XjNw58ToQwJFO7avZZbPecSYgw8PBYhd0An+4JtZSrSzKhEvTsVV2uqdO7VziWTOGSgLGD5YRPdsCjF7Q==", + "dev": true, + "dependencies": { + "encoding": "^0.1.12", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/gettext-to-messageformat": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/gettext-to-messageformat/-/gettext-to-messageformat-0.3.1.tgz", + "integrity": "sha512-UyqIL3Ul4NryU95Wome/qtlcuVIqgEWVIFw0zi7Lv14ACLXfaVDCbrjZ7o+3BZ7u+4NS1mP/2O1eXZoHCoas8g==", + "dev": true, + "dependencies": { + "gettext-parser": "^1.4.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gettext-to-messageformat/node_modules/gettext-parser": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-1.4.0.tgz", + "integrity": "sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA==", + "dev": true, + "dependencies": { + "encoding": "^0.1.12", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/gettext.js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/gettext.js/-/gettext.js-2.0.2.tgz", + "integrity": "sha512-V5S0HCgOzHmTaaHUNMQr3xVPu6qLpv3j6K9Ygi41Pu6SRNNmWwvXCf/wQYXArznm4uZyZ1v8yrDZngxy6+kCyw==", + "dev": true, + "dependencies": { + "po2json": "^1.0.0-beta-3" + }, + "bin": { + "po2json-gettextjs": "bin/po2json" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -10775,18 +10808,6 @@ "lz-string": "bin/bin.js" } }, - "node_modules/magic-string": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", - "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -11700,6 +11721,37 @@ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" }, + "node_modules/po2json": { + "version": "1.0.0-beta-3", + "resolved": "https://registry.npmjs.org/po2json/-/po2json-1.0.0-beta-3.tgz", + "integrity": "sha512-taS8y6ZEGzPAs0rygW9CuUPY8C3Zgx6cBy31QXxG2JlWS3fLxj/kuD3cbIfXBg30PuYN7J5oyBa/TIRjyqFFtg==", + "dev": true, + "dependencies": { + "commander": "^6.0.0", + "gettext-parser": "2.0.0", + "gettext-to-messageformat": "0.3.1" + }, + "bin": { + "po2json": "bin/po2json" + }, + "engines": { + "node": ">=10.0" + }, + "peerDependencies": { + "commander": "^6.0.0", + "gettext-parser": "2.0.0", + "gettext-to-messageformat": "0.3.1" + } + }, + "node_modules/po2json/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -15327,38 +15379,6 @@ "webpack": "^4.0.0 || ^5.0.0" } }, - "node_modules/webpack-localize-assets-plugin": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/webpack-localize-assets-plugin/-/webpack-localize-assets-plugin-1.5.4.tgz", - "integrity": "sha512-3+iBEnDKm5d1EweaVpFv57lA03ZNFtufBnjwZH8Bz27lT3l7dbANd+i0adSA9G87NzeIo487kk/0SutUqI9x8w==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0", - "acorn": "^8.8.2", - "astring": "^1.8.4", - "magic-string": "^0.27.0", - "webpack-sources": "^2.2.0" - }, - "funding": { - "url": "https://github.com/privatenumber/webpack-localize-assets-plugin?sponsor=1" - }, - "peerDependencies": { - "webpack": "^4.42.0 || ^5.10.0" - } - }, - "node_modules/webpack-localize-assets-plugin/node_modules/webpack-sources": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", - "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", - "dev": true, - "dependencies": { - "source-list-map": "^2.0.1", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/webpack-manifest-plugin": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-5.0.0.tgz", diff --git a/package.json b/package.json index bd222a9c1b27..f0b900aebba3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "css-loader": "^6.7.3", "css-minimizer-webpack-plugin": "^5.0.0", "eslint": "^8.36.0", + "gettext.js": "^2.0.2", "glob": "^10.2.2", "image-minimizer-webpack-plugin": "^3.8.1", "jest": "^29.5.0", @@ -64,7 +65,6 @@ "webpack": "^5.80.0", "webpack-cli": "^5.0.2", "webpack-livereload-plugin": "^3.0.2", - "webpack-localize-assets-plugin": "^1.5.4", "webpack-manifest-plugin": "^5.0.0", "webpack-remove-empty-scripts": "^1.0.3" }, diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html index b617b447eb7a..250244c68fd2 100644 --- a/warehouse/admin/templates/admin/base.html +++ b/warehouse/admin/templates/admin/base.html @@ -267,7 +267,7 @@

{{ self.title()|default('Dashboard', true) }}

- + diff --git a/warehouse/locale/Makefile b/warehouse/locale/Makefile index bf8aa397a9fd..24f630c2bf8f 100644 --- a/warehouse/locale/Makefile +++ b/warehouse/locale/Makefile @@ -5,6 +5,7 @@ compile-pot: PYTHONPATH=$(PWD) pybabel extract \ -F babel.cfg \ --omit-header \ + --ignore-dirs=".* ._ dist" \ --output="warehouse/locale/messages.pot" \ warehouse diff --git a/warehouse/locale/de/LC_MESSAGES/messages.json b/warehouse/locale/de/LC_MESSAGES/messages.json index 3c89063cb0db..ad71dcac1840 100644 --- a/warehouse/locale/de/LC_MESSAGES/messages.json +++ b/warehouse/locale/de/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=2; plural=n != 1;", "entries": []} \ No newline at end of file +{"locale": "de", "plural-forms": "nplurals=2; plural=n != 1;", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/el/LC_MESSAGES/messages.json b/warehouse/locale/el/LC_MESSAGES/messages.json index 3c89063cb0db..302860174217 100644 --- a/warehouse/locale/el/LC_MESSAGES/messages.json +++ b/warehouse/locale/el/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=2; plural=n != 1;", "entries": []} \ No newline at end of file +{"locale": "el", "plural-forms": "nplurals=2; plural=n != 1;", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/eo/LC_MESSAGES/messages.json b/warehouse/locale/eo/LC_MESSAGES/messages.json index 3c89063cb0db..72867add5153 100644 --- a/warehouse/locale/eo/LC_MESSAGES/messages.json +++ b/warehouse/locale/eo/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=2; plural=n != 1;", "entries": []} \ No newline at end of file +{"locale": "eo", "plural-forms": "nplurals=2; plural=n != 1;", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/es/LC_MESSAGES/messages.json b/warehouse/locale/es/LC_MESSAGES/messages.json index 3c89063cb0db..dd9a06ebdbb2 100644 --- a/warehouse/locale/es/LC_MESSAGES/messages.json +++ b/warehouse/locale/es/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=2; plural=n != 1;", "entries": []} \ No newline at end of file +{"locale": "es", "plural-forms": "nplurals=2; plural=n != 1;", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/fr/LC_MESSAGES/messages.json b/warehouse/locale/fr/LC_MESSAGES/messages.json index 447a4e48b1ac..8a55afdec084 100644 --- a/warehouse/locale/fr/LC_MESSAGES/messages.json +++ b/warehouse/locale/fr/LC_MESSAGES/messages.json @@ -1,110 +1 @@ -{ - "plural-forms": "nplurals=2; plural=n > 1;", - "entries": [ - { - "flags": [], - "msgctxt": null, - "msgid": "Error while validating hashed password, disregard on development", - "msgid_plural": "", - "msgid_with_context": "Error while validating hashed password, disregard on development", - "msgstr": "FR: Error while validating hashed password, disregard on development", - "msgstr_plural": {}, - "prev_msgctxt": null, - "prev_msgid": null, - "prev_msgid_plural": null - }, - { - "flags": [], - "msgctxt": null, - "msgid": "Passwords match", - "msgid_plural": "", - "msgid_with_context": "Passwords match", - "msgstr": "FR: Passwords match", - "msgstr_plural": {}, - "prev_msgctxt": null, - "prev_msgid": null, - "prev_msgid_plural": null - }, - { - "flags": [], - "msgctxt": null, - "msgid": "Passwords do not match", - "msgid_plural": "", - "msgid_with_context": "Passwords do not match", - "msgstr": "FR: Passwords do not match", - "msgstr_plural": {}, - "prev_msgctxt": null, - "prev_msgid": null, - "prev_msgid_plural": null - }, - { - "flags": [], - "msgctxt": null, - "msgid": "Password field is empty", - "msgid_plural": "", - "msgid_with_context": "Password field is empty", - "msgstr": "FR: Password field is empty", - "msgstr_plural": {}, - "prev_msgctxt": null, - "prev_msgid": null, - "prev_msgid_plural": null - }, - { - "flags": [], - "msgctxt": null, - "msgid": "Password is strong", - "msgid_plural": "", - "msgid_with_context": "Password is strong", - "msgstr": "FR: Password is strong", - "msgstr_plural": {}, - "prev_msgctxt": null, - "prev_msgid": null, - "prev_msgid_plural": null - }, - { - "flags": [], - "msgctxt": null, - "msgid": "Yesterday", - "msgid_plural": "About %1 days ago", - "msgid_with_context": "Yesterday", - "msgstr": "", - "msgstr_plural": { - "0": "FR: Yesterday", - "1": "FR: About %1 days ago" - }, - "prev_msgctxt": null, - "prev_msgid": null, - "prev_msgid_plural": null - }, - { - "flags": [], - "msgctxt": null, - "msgid": "About an hour ago", - "msgid_plural": "About %1 hours ago", - "msgid_with_context": "About an hour ago", - "msgstr": "", - "msgstr_plural": { - "0": "FR: About an hour ago", - "1": "FR: About %1 hours ago" - }, - "prev_msgctxt": null, - "prev_msgid": null, - "prev_msgid_plural": null - }, - { - "flags": [], - "msgctxt": null, - "msgid": "About a minute ago", - "msgid_plural": "About %1 minutes ago", - "msgid_with_context": "About a minute ago", - "msgstr": "", - "msgstr_plural": { - "0": "FR: About a minute ago", - "1": "FR: About %1 minutes ago" - }, - "prev_msgctxt": null, - "prev_msgid": null, - "prev_msgid_plural": null - } - ] -} +{"locale": "fr", "plural-forms": "nplurals=2; plural=n > 1;", "entries": {"Error while validating hashed password, disregard on development": {"flags": [], "msgctxt": null, "msgid": "Error while validating hashed password, disregard on development", "msgid_plural": "", "msgid_with_context": "Error while validating hashed password, disregard on development", "msgstr": "FR: Error while validating hashed password, disregard on development", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Passwords match": {"flags": [], "msgctxt": null, "msgid": "Passwords match", "msgid_plural": "", "msgid_with_context": "Passwords match", "msgstr": "FR: Passwords match", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Passwords do not match": {"flags": [], "msgctxt": null, "msgid": "Passwords do not match", "msgid_plural": "", "msgid_with_context": "Passwords do not match", "msgstr": "FR: Passwords do not match", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Password field is empty": {"flags": [], "msgctxt": null, "msgid": "Password field is empty", "msgid_plural": "", "msgid_with_context": "Password field is empty", "msgstr": "FR: Password field is empty", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Password is strong": {"flags": [], "msgctxt": null, "msgid": "Password is strong", "msgid_plural": "", "msgid_with_context": "Password is strong", "msgstr": "FR: Password is strong", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Yesterday": {"flags": [], "msgctxt": null, "msgid": "Yesterday", "msgid_plural": "About %1 days ago", "msgid_with_context": "Yesterday", "msgstr": "", "msgstr_plural": {"0": "FR: Yesterday", "1": "FR: About %1 days ago"}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "About an hour ago": {"flags": [], "msgctxt": null, "msgid": "About an hour ago", "msgid_plural": "About %1 hours ago", "msgid_with_context": "About an hour ago", "msgstr": "", "msgstr_plural": {"0": "FR: About an hour ago", "1": "FR: About %1 hours ago"}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "About a minute ago": {"flags": [], "msgctxt": null, "msgid": "About a minute ago", "msgid_plural": "About %1 minutes ago", "msgid_with_context": "About a minute ago", "msgstr": "", "msgstr_plural": {"0": "FR: About a minute ago", "1": "FR: About %1 minutes ago"}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}}} \ No newline at end of file diff --git a/warehouse/locale/he/LC_MESSAGES/messages.json b/warehouse/locale/he/LC_MESSAGES/messages.json index 05c496c5b96d..89c8a99a7479 100644 --- a/warehouse/locale/he/LC_MESSAGES/messages.json +++ b/warehouse/locale/he/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3));", "entries": []} \ No newline at end of file +{"locale": "he", "plural-forms": "nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3));", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/ja/LC_MESSAGES/messages.json b/warehouse/locale/ja/LC_MESSAGES/messages.json index 78afcb065eb1..50ccd95faa3e 100644 --- a/warehouse/locale/ja/LC_MESSAGES/messages.json +++ b/warehouse/locale/ja/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=1; plural=0;", "entries": []} \ No newline at end of file +{"locale": "ja", "plural-forms": "nplurals=1; plural=0;", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/pt_BR/LC_MESSAGES/messages.json b/warehouse/locale/pt_BR/LC_MESSAGES/messages.json index 3c7d040a2b46..69594e7a2e9d 100644 --- a/warehouse/locale/pt_BR/LC_MESSAGES/messages.json +++ b/warehouse/locale/pt_BR/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=2; plural=n > 1;", "entries": []} \ No newline at end of file +{"locale": "pt_BR", "plural-forms": "nplurals=2; plural=n > 1;", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/ru/LC_MESSAGES/messages.json b/warehouse/locale/ru/LC_MESSAGES/messages.json index 0b95087acbc7..bd7a9991f554 100644 --- a/warehouse/locale/ru/LC_MESSAGES/messages.json +++ b/warehouse/locale/ru/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "entries": []} \ No newline at end of file +{"locale": "ru", "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/uk/LC_MESSAGES/messages.json b/warehouse/locale/uk/LC_MESSAGES/messages.json index 0b95087acbc7..4671c004ef82 100644 --- a/warehouse/locale/uk/LC_MESSAGES/messages.json +++ b/warehouse/locale/uk/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "entries": []} \ No newline at end of file +{"locale": "uk", "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json b/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json index 78afcb065eb1..c1b1c6168990 100644 --- a/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json +++ b/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=1; plural=0;", "entries": []} \ No newline at end of file +{"locale": "zh_Hans", "plural-forms": "nplurals=1; plural=0;", "entries": {}} \ No newline at end of file diff --git a/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json b/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json index 78afcb065eb1..7bf19e0c69ec 100644 --- a/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json +++ b/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"plural-forms": "nplurals=1; plural=0;", "entries": []} \ No newline at end of file +{"locale": "zh_Hant", "plural-forms": "nplurals=1; plural=0;", "entries": {}} \ No newline at end of file diff --git a/warehouse/static/js/warehouse/utils/messages-access.js b/warehouse/static/js/warehouse/utils/messages-access.js index 76d20771eae3..354c205c3515 100644 --- a/warehouse/static/js/warehouse/utils/messages-access.js +++ b/warehouse/static/js/warehouse/utils/messages-access.js @@ -11,6 +11,7 @@ * limitations under the License. */ +const i18n = require("gettext.js/dist/gettext.cjs.js"); /** * Get the translation using num to choose the appropriate string. @@ -31,12 +32,23 @@ * @param singular {string} The default string for the singular translation. * @param plural {string} The default string for the plural translation. * @param num {number} The number to use to select the appropriate translation. + * @param extras {string} Additional values to put in placeholders. * @returns {string} The translated text. * @see https://www.gnu.org/software/gettext/manual/gettext.html#Language-specific-options * @see https://docs.pylonsproject.org/projects/pyramid/en/latest/api/i18n.html#pyramid.i18n.Localizer.pluralize */ -export function ngettext(singular, plural, num) { - return JSON.stringify({singular: singular, plural: plural, num: num}); +export function ngettext(singular, plural, num, ...extras) { + const singularIsString = typeof singular === "string" || singular instanceof String; + if(singularIsString) { + // construct the translation using the fallback language (english) + i18n.setMessages("messages", "en", {[singular]:[singular, plural]}, "nplurals = 2; plural = (n != 1)"); + } else { + // After the webpack localizer processing, + // the non-string 'singular' is the translation data. + i18n.loadJSON(singular.data, "messages"); + } + + return i18n.ngettext(singular.singular, plural, num, ...extras); } /** @@ -49,20 +61,19 @@ export function ngettext(singular, plural, num) { * Any placeholders must be specified as * * @param singular {string} The default string for the singular translation. + * @param extras {string} Additional values to put in placeholders. * @returns {string} The translated text. */ -export function gettext(singular) { - return JSON.stringify({singular: singular}); -} +export function gettext(singular, ...extras) { + const singularIsString = typeof singular === "string" || singular instanceof String; + if(singularIsString) { + // construct the translation using the fallback language (english) + i18n.setMessages("messages", "en", {[singular]:[singular]}, "nplurals = 2; plural = (n != 1)"); + } else { + // After the webpack localizer processing, + // the non-string 'singular' is the translation data. + i18n.loadJSON(singular.data, "messages"); + } -export function templateMessage(strings, ...keys) { - return (...values) => { - const dict = values[values.length - 1] || {}; - const result = [strings[0]]; - keys.forEach((key, i) => { - const value = Number.isInteger(key) ? values[key] : dict[key]; - result.push(value, strings[i + 1]); - }); - return result.join(""); - }; + return i18n.gettext(singular.singular, ...extras); } diff --git a/webpack.config.js b/webpack.config.js index 304f45db211c..fbdc14c1b82d 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -27,8 +27,8 @@ const LiveReloadPlugin = require("webpack-livereload-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const ProvidePlugin = require("webpack").ProvidePlugin; const RemoveEmptyScriptsPlugin = require("webpack-remove-empty-scripts"); -const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); -const LocalizeAssetsPlugin = require("webpack-localize-assets-plugin"); +const {WebpackManifestPlugin} = require("webpack-manifest-plugin"); +const {WebpackLocalisationPlugin, localeData} = require("./webpack.plugin.localize.js"); /* Shared Plugins */ @@ -36,7 +36,7 @@ const sharedCompressionPlugins = [ new CompressionPlugin({ filename: "[path][base].gz", algorithm: "gzip", - compressionOptions: { level: 9, memLevel: 9 }, + compressionOptions: {level: 9, memLevel: 9}, // Only compress files that will actually be smaller when compressed. minRatio: 1, }), @@ -63,334 +63,340 @@ const sharedCSSPlugins = [ new RemoveEmptyScriptsPlugin(), ]; -const sharedWebpackManifestPlugins = [ - new WebpackManifestPlugin({ - // Replace each entry with a prefix of a subdirectory. - // NOTE: This could be removed if we update the HTML to use the non-prefixed - // paths. - map: (file) => { - // if the filename matches .js or .js.map, add js/ prefix if not already present - if (file.name.match(/\.js(\.map)?$/)) { - if (!file.name.startsWith("js/")) { - file.name = `js/${file.name}`; // eslint-disable-line no-param-reassign - } +const sharedWebpackManifestData = {}; +const sharedWebpackManifestMap = + // Replace each entry with a prefix of a subdirectory. + // NOTE: This could be removed if we update the HTML to use the non-prefixed + // paths. + (file) => { + // if the filename matches .js or .js.map, add js/ prefix if not already present + if (file.name.match(/\.js(\.map)?$/)) { + if (!file.name.startsWith("js/")) { + file.name = `js/${file.name}`; // eslint-disable-line no-param-reassign } - // if the filename matches .css or .css.map, add a prefix of css/ - if (file.name.match(/\.css(\.map)?$/)) { - file.name = `css/${file.name}`; // eslint-disable-line no-param-reassign - } - return file; - }, - // Refs: https://github.com/shellscape/webpack-manifest-plugin/issues/229#issuecomment-737617994 - publicPath: "", - }), -]; - -// The translations here must match the KNOWN_LOCALES in warehouse/i18n/__init__.py -// Without 'en', as that is the default language. -const KNOWN_LOCALES = [ - "es", - "fr", - "ja", - "pt_BR", - "uk", - "el", - "de", - "zh_Hans", - "zh_Hant", - "ru", - "he", - "eo", -]; -const translations = {}; -const plural_forms = {}; -KNOWN_LOCALES.forEach((locale) => { - const messages = path.resolve(__dirname, `warehouse/locale/${locale}/LC_MESSAGES/messages.json`); - translations[locale] = messages.entries; - plural_forms[locale] = messages["plural-forms"]; -}); -const sharedTranslationPlugins = [ - new LocalizeAssetsPlugin({ - locales: translations, - sourceMapForLocales: KNOWN_LOCALES, - throwOnMissing: true, - warnOnUnusedString: true, - localizeCompiler: { - gettext(callArguments, localeName) { - const [singular] = callArguments; - const availableForms = this.resolveKey(singular); - return `gettext("${availableForms[0]}")/*${localeName}*/`; - }, - ngettext(callArguments, localeName) { - const [singular, plural, num] = callArguments; - const availableForms = this.resolveKey(singular); - return `ngettext("${availableForms[0]}","${JSON.stringify(availableForms)}",${num})/*${localeName},plural:${plural}*/`; - }, - }, - }), -]; + } + // if the filename matches .css or .css.map, add a prefix of css/ + if (file.name.match(/\.css(\.map)?$/)) { + file.name = `css/${file.name}`; // eslint-disable-line no-param-reassign + } + return file; + }; +// Refs: https://github.com/shellscape/webpack-manifest-plugin/issues/229#issuecomment-737617994 +// publicPath: "", /* End Shared Plugins */ const sharedResolve = { alias: { - // Use an alias to make inline non-relative `@import` statements. + // Use an alias to make inline non-relative `@import` statements. warehouse: path.resolve(__dirname, "warehouse/static/js/warehouse"), }, }; -module.exports = [ - { - stats: { - errors: true, - children: true, - logging: "verbose", - loggingDebug: ["LocalizeAssetsPlugin"], - }, - name: "warehouse", - experiments: { - // allow us to manage RTL CSS as a separate file - layers: true, - }, +// for each language locale, generate config for warehouse +const modulesLocales = localeData.map(function (data) { + const name = `warehouse.${data.locale}`; + return { + // stats: { + // errors: true, + // children: true, + // logging: "verbose", + // }, + name: name, plugins: [ - new CopyPlugin({ - patterns: [ - { - // Most images are not referenced in JS/CSS, copy them manually. - from: path.resolve(__dirname, "warehouse/static/images/*"), - to: "images/[name].[contenthash][ext]", - }, - { - // Copy vendored zxcvbn code - from: path.resolve(__dirname, "warehouse/static/js/vendor/zxcvbn.js"), - to: "js/vendor/[name].[contenthash][ext]", - }, - ], - }), - ...sharedTranslationPlugins, + new WebpackLocalisationPlugin({localeData:data}), ...sharedCompressionPlugins, - ...sharedCSSPlugins, - ...sharedWebpackManifestPlugins, + new WebpackManifestPlugin({ + removeKeyHash: /([a-f0-9]{8}\.?)/gi, + publicPath: "", + seed: sharedWebpackManifestData, + map: sharedWebpackManifestMap, + }), new LiveReloadPlugin(), ], resolve: sharedResolve, entry: { - // Webpack will create a bundle for each entry point. + // Webpack will create a bundle for each entry point. /* JavaScript */ - warehouse: { + [name]: { import: "./warehouse/static/js/warehouse/index.js", // override the filename from `index` to `warehouse` - filename: "js/warehouse.[locale].[contenthash].js", + filename: `js/${name}.[contenthash].js`, }, - - /* CSS */ - noscript: "./warehouse/static/sass/noscript.scss", - - // Default CSS - "warehouse-ltr": "./warehouse/static/sass/warehouse.scss", - // NOTE: Duplicate RTL CSS target. There's no clean way to generate both - // without duplicating the entry point right now. - "warehouse-rtl": { - import: "./warehouse/static/sass/warehouse.scss", - layer: "rtl", - }, - - /* Vendor Stuff */ - fontawesome: "./warehouse/static/sass/vendor/fontawesome.scss", }, // The default source map. Slowest, but best production-build optimizations. // See: https://webpack.js.org/configuration/devtool devtool: "source-map", output: { - // remove old files - clean: true, // Matches current behavior. Defaults to 20. 16 in the future. hashDigestLength: 8, // Global filename template for all assets. Other assets MUST override. - filename: "[name].[locale].[contenthash].js", + filename: "[name].[contenthash].js", // Global output path for all assets. path: path.resolve(__dirname, "warehouse/static/dist"), }, - module: { - rules: [ + dependencies: ["warehouse"], + }; + +}); +const moduleWarehouse = { + // stats: { + // errors: true, + // children: true, + // logging: "verbose", + // }, + name: "warehouse", + experiments: { + // allow us to manage RTL CSS as a separate file + layers: true, + }, + plugins: [ + new CopyPlugin({ + patterns: [ { + // Most images are not referenced in JS/CSS, copy them manually. + from: path.resolve(__dirname, "warehouse/static/images/*"), + to: "images/[name].[contenthash][ext]", + }, + { + // Copy vendored zxcvbn code + from: path.resolve(__dirname, "warehouse/static/js/vendor/zxcvbn.js"), + to: "js/vendor/[name].[contenthash][ext]", + }, + ], + }), + ...sharedCompressionPlugins, + ...sharedCSSPlugins, + new WebpackManifestPlugin({ + removeKeyHash: /([a-f0-9]{8}\.?)/gi, + publicPath: "", + seed: sharedWebpackManifestData, + map: sharedWebpackManifestMap + }), + new LiveReloadPlugin(), + ], + resolve: sharedResolve, + entry: { + // Webpack will create a bundle for each entry point. + + /* JavaScript */ + warehouse: { + import: "./warehouse/static/js/warehouse/index.js", + // override the filename from `index` to `warehouse` + filename: "js/warehouse.[contenthash].js", + }, + + /* CSS */ + noscript: "./warehouse/static/sass/noscript.scss", + + // Default CSS + "warehouse-ltr": "./warehouse/static/sass/warehouse.scss", + // NOTE: Duplicate RTL CSS target. There's no clean way to generate both + // without duplicating the entry point right now. + "warehouse-rtl": { + import: "./warehouse/static/sass/warehouse.scss", + layer: "rtl", + }, + + /* Vendor Stuff */ + fontawesome: "./warehouse/static/sass/vendor/fontawesome.scss", + }, + // The default source map. Slowest, but best production-build optimizations. + // See: https://webpack.js.org/configuration/devtool + devtool: "source-map", + output: { + // remove old files + clean: true, + // Matches current behavior. Defaults to 20. 16 in the future. + hashDigestLength: 8, + // Global filename template for all assets. Other assets MUST override. + filename: "[name].[contenthash].js", + // Global output path for all assets. + path: path.resolve(__dirname, "warehouse/static/dist"), + }, + module: { + rules: [ + { // Handle SASS/SCSS/CSS files - test: /\.(sa|sc|c)ss$/, - // NOTE: Order is important here, as the first match wins - oneOf: [ - { + test: /\.(sa|sc|c)ss$/, + // NOTE: Order is important here, as the first match wins + oneOf: [ + { // For the `rtl` file, needs postcss processing - layer: "rtl", - issuerLayer: "rtl", - use: [ - MiniCssExtractPlugin.loader, - "css-loader", - { - loader: "postcss-loader", - options: { - postcssOptions: { - plugins: [rtlcss()], - }, + layer: "rtl", + issuerLayer: "rtl", + use: [ + MiniCssExtractPlugin.loader, + "css-loader", + { + loader: "postcss-loader", + options: { + postcssOptions: { + plugins: [rtlcss()], }, }, - "sass-loader", - ], - }, - { + }, + "sass-loader", + ], + }, + { // All other CSS files - use: [ + use: [ // Extracts CSS into separate files - MiniCssExtractPlugin.loader, - // Translates CSS into CommonJS - "css-loader", - // Translate SCSS to CSS - "sass-loader", - ], - }, - ], - }, - { - // Handle image files - test: /\.(png|svg|jpg|jpeg|gif)$/i, - // disables data URL inline encoding images into CSS, - // since it violates our CSP settings. - type: "asset/resource", - generator: { - filename: "images/[name].[contenthash][ext]", + MiniCssExtractPlugin.loader, + // Translates CSS into CommonJS + "css-loader", + // Translate SCSS to CSS + "sass-loader", + ], }, + ], + }, + { + // Handle image files + test: /\.(png|svg|jpg|jpeg|gif)$/i, + // disables data URL inline encoding images into CSS, + // since it violates our CSP settings. + type: "asset/resource", + generator: { + filename: "images/[name].[contenthash][ext]", }, - { + }, + { // Handle font files - test: /\.(woff|woff2|eot|ttf|otf)$/i, - type: "asset/resource", - generator: { - filename: "webfonts/[name].[contenthash][ext]", - }, + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: "asset/resource", + generator: { + filename: "webfonts/[name].[contenthash][ext]", }, - ], - }, - optimization: { - minimizer: [ + }, + ], + }, + optimization: { + minimizer: [ // default minimizer is Terser for JS. Extend here vs overriding. - "...", - // Minimize CSS - new CssMinimizerPlugin({ - minimizerOptions: { - preset: [ - "default", - { - discardComments: { removeAll: true }, - }, - ], - }, - }), - // Minimize Images when `mode` is `production` - new ImageMinimizerPlugin({ - test: /\.(png|jpg|jpeg|gif)$/i, - minimizer: { - implementation: ImageMinimizerPlugin.sharpMinify, - }, - generator: [ + "...", + // Minimize CSS + new CssMinimizerPlugin({ + minimizerOptions: { + preset: [ + "default", { - // Apply generator for copied assets - type: "asset", - implementation: ImageMinimizerPlugin.sharpGenerate, - options: { - encodeOptions: { - webp: { - quality: 90, - }, - }, - }, + discardComments: {removeAll: true}, }, ], - }), - new ImageMinimizerPlugin({ - test: /\.(svg)$/i, - minimizer: { - implementation: ImageMinimizerPlugin.svgoMinify, + }, + }), + // Minimize Images when `mode` is `production` + new ImageMinimizerPlugin({ + test: /\.(png|jpg|jpeg|gif)$/i, + minimizer: { + implementation: ImageMinimizerPlugin.sharpMinify, + }, + generator: [ + { + // Apply generator for copied assets + type: "asset", + implementation: ImageMinimizerPlugin.sharpGenerate, options: { encodeOptions: { - // Pass over SVGs multiple times to ensure all optimizations are applied. False by default - multipass: true, - plugins: [ - // set of built-in plugins enabled by default - // see: https://github.com/svg/svgo#default-preset - "preset-default", - ], + webp: { + quality: 90, + }, }, }, }, - }), - ], - }, - }, - { - stats: { - errors: true, - children: true, - logging: "verbose", - loggingDebug: ["LocalizeAssetsPlugin"], - }, - name: "admin", - plugins: [ - ...sharedTranslationPlugins, - ...sharedCompressionPlugins, - ...sharedCSSPlugins, - ...sharedWebpackManifestPlugins, - // admin site dependencies use jQuery - new ProvidePlugin({ - $: "jquery", - jQuery: "jquery", + ], + }), + new ImageMinimizerPlugin({ + test: /\.(svg)$/i, + minimizer: { + implementation: ImageMinimizerPlugin.svgoMinify, + options: { + encodeOptions: { + // Pass over SVGs multiple times to ensure all optimizations are applied. False by default + multipass: true, + plugins: [ + // set of built-in plugins enabled by default + // see: https://github.com/svg/svgo#default-preset + "preset-default", + ], + }, + }, + }, }), - new LiveReloadPlugin(), ], - resolve: sharedResolve, - entry: { - admin: { - import: "./warehouse/admin/static/js/warehouse.js", - filename: "js/admin.[locale].[contenthash].js", - }, - all: { - import: "./warehouse/admin/static/css/admin.scss", - }, + }, +}; + +const moduleAdmin = { + name: "admin", + plugins: [ + ...sharedCompressionPlugins, + ...sharedCSSPlugins, + new WebpackManifestPlugin({ + removeKeyHash: /([a-f0-9]{8}\.?)/gi, + publicPath: "", + map: sharedWebpackManifestMap, + }), + // admin site dependencies use jQuery + new ProvidePlugin({ + $: "jquery", + jQuery: "jquery", + }), + new LiveReloadPlugin(), + ], + resolve: sharedResolve, + entry: { + admin: { + import: "./warehouse/admin/static/js/warehouse.js", + filename: "js/admin.[contenthash].js", }, - devtool: "source-map", - output: { - clean: true, - hashDigestLength: 8, - filename: "[name].[locale].[contenthash].js", - path: path.resolve(__dirname, "warehouse/admin/static/dist"), + all: { + import: "./warehouse/admin/static/css/admin.scss", }, - module: { - rules: [ - { - test: /\.(sa|sc|c)ss$/, - use: [ - MiniCssExtractPlugin.loader, - "css-loader", - "sass-loader", - ], - }, - { + }, + devtool: "source-map", + output: { + clean: true, + hashDigestLength: 8, + filename: "[name].[contenthash].js", + path: path.resolve(__dirname, "warehouse/admin/static/dist"), + }, + module: { + rules: [ + { + test: /\.(sa|sc|c)ss$/, + use: [ + MiniCssExtractPlugin.loader, + "css-loader", + "sass-loader", + ], + }, + { // Handle image files - test: /\.(png|svg|jpg|jpeg|gif)$/i, - // disables data URL inline encoding images into CSS, - // since it violates our CSP settings. - type: "asset/resource", - generator: { - filename: "images/[name].[contenthash][ext]", - }, + test: /\.(png|svg|jpg|jpeg|gif)$/i, + // disables data URL inline encoding images into CSS, + // since it violates our CSP settings. + type: "asset/resource", + generator: { + filename: "images/[name].[contenthash][ext]", }, - { - test: /\.(woff|woff2|eot|ttf|otf)$/i, - type: "asset/resource", - generator: { - filename: "fonts/[name].[contenthash][ext]", - }, + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: "asset/resource", + generator: { + filename: "fonts/[name].[contenthash][ext]", }, - ], - }, + }, + ], }, +}; + +module.exports = [ + moduleWarehouse, + moduleAdmin, + ...modulesLocales, ]; diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js new file mode 100644 index 000000000000..57988f09a2d4 --- /dev/null +++ b/webpack.plugin.localize.js @@ -0,0 +1,154 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* This is a webpack plugin. + * + * This plugin generates one javascript bundle per locale. + * It replaces the javascript translation function arguments with the locale-specific data. + * + * gettext.js is then used to determine the translation to use and replace placeholders. + * The javascript functions are in `warehouse/static/js/warehouse/utils/messages-access.js`. + * + * Run 'make translations' to generate the 'messages.json' files for the KNOWN_LOCALES. + */ + +// ref: https://webpack.js.org/contribute/writing-a-plugin/ +// ref: https://github.com/zainulbr/i18n-webpack-plugin/blob/v2.0.3/src/index.js +// ref: https://github.com/webpack/webpack/discussions/14956 +// ref: https://github.com/webpack/webpack/issues/9992 + +const ConstDependency = require("webpack/lib/dependencies/ConstDependency"); +const fs = require("node:fs"); +const {resolve} = require("node:path"); +const path = require("path"); + +// load the locale translation data +const baseDir = path.resolve(__dirname, "warehouse/locale"); +const localeData = fs.readdirSync(baseDir) + .map((file) => resolve(baseDir, file, "LC_MESSAGES/messages.json")) + .filter((file) => { + try { + return fs.statSync(file).isFile(); + } catch { + } + }) + .map((file) => { + console.log(`Translations from ${path.relative(__dirname, file)}`); + return fs.readFileSync(file, "utf8"); + }) + .map((data) => JSON.parse(data)); + +// TODO: don't allow changing the WebpackLocalisationPlugin.functions - just do what is needed +const defaultFunctionGetTextJs = function (data, singular) { + let value; + if (Object.hasOwn(data.entries, singular)) { + value = JSON.stringify({ + "singular": singular, + "data": { + "": { + "locale": data.locale, + "plural-forms": data["plural-forms"], + }, + [singular]: Object.entries(data.entries[singular].msgstr_plural).map((entry) => entry[1]), + }, + }); + } else { + value = `"${singular}"`; + } + return value; +}; +const defaultFunctionExtras = function (extras) { + let extrasString = ""; + if (extras.length > 0) { + extrasString = `, "${extras.join("\", \"")}"`; + } + return extrasString; +}; +const defaultFunctions = { + gettext: (data, singular, ...extras) => { + const value = defaultFunctionGetTextJs(data, singular); + const extrasString = defaultFunctionExtras(extras); + return `gettext(${value} ${extrasString})`; + }, + ngettext: (data, singular, plural, num, ...extras) => { + const value = defaultFunctionGetTextJs(data, singular); + const extrasString = defaultFunctionExtras(extras); + return `ngettext(${value}, "${plural}", ${num} ${extrasString})`; + }, +}; + +class WebpackLocalisationPlugin { + constructor(options) { + const opts = options || {}; + this.localeData = opts.localeData || {}; + this.functions = opts.functions || defaultFunctions; + } + + apply(compiler) { + const pluginName = "WebpackLocalisationPlugin"; + const self = this; + + // create a handler for each factory.hooks.parser + const handler = function (parser) { + + // for each function name and processing function + Object.keys(self.functions).forEach(function (findFuncName) { + const pluginTagImport = Symbol(`${pluginName}-import-tag-${findFuncName}`); + const replacementFunction = self.functions[findFuncName]; + + // tag imports so can later hook into their usages + parser.hooks.importSpecifier.tap(pluginName, (statement, source, exportName, identifierName) => { + if (exportName === findFuncName && identifierName === findFuncName) { + parser.tagVariable(identifierName, pluginTagImport, {}); + return true; + } + }); + + // hook into calls of the tagged imported function + parser.hooks.call.for(pluginTagImport).tap(pluginName, expr => { + try { + // pass the appropriate information for each type of argument + // TODO: pass expr.arguments directly so the information is available + let replacementValue = replacementFunction(self.localeData, ...expr.arguments.map((argument) => { + if (argument.type === "Literal") { + return argument.value; + } else if (argument.type === "Identifier") { + return argument.name; + } else { + throw new Error(`Unknown argument type '${argument.type}'.`); + } + })); + const dep = new ConstDependency(replacementValue, expr.range); + dep.loc = expr.loc; + parser.state.current.addDependency(dep); + return true; + } catch (err) { + parser.state.module.errors.push(err); + } + }); + + }); + }; + + // place the hooks into the webpack compiler, factories + compiler.hooks.normalModuleFactory.tap(pluginName, factory => { + factory.hooks.parser.for("javascript/auto").tap(pluginName, handler); + factory.hooks.parser.for("javascript/dynamic").tap(pluginName, handler); + factory.hooks.parser.for("javascript/esm").tap(pluginName, handler); + }); + + } +} + +module.exports.WebpackLocalisationPlugin = WebpackLocalisationPlugin; +module.exports.localeData = localeData; From 11f093a9c3f0a3c67877ab2ad895dea1d447691f Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 21 Apr 2024 00:21:34 +1000 Subject: [PATCH 28/52] implemented webpack localize plugin --- bin/translations-js.mjs | 110 ---- bin/translations_json.py | 28 +- docs/dev/development/frontend.rst | 19 + docs/dev/development/reviewing-patches.rst | 4 +- docs/dev/translations.rst | 25 +- warehouse/locale/Makefile | 2 +- warehouse/locale/am/LC_MESSAGES/messages.po | 8 +- warehouse/locale/de/LC_MESSAGES/messages.json | 2 +- warehouse/locale/el/LC_MESSAGES/messages.json | 2 +- warehouse/locale/eo/LC_MESSAGES/messages.json | 2 +- warehouse/locale/es/LC_MESSAGES/messages.json | 2 +- warehouse/locale/fr/LC_MESSAGES/messages.json | 2 +- warehouse/locale/fr/LC_MESSAGES/messages.po | 22 +- warehouse/locale/he/LC_MESSAGES/messages.json | 2 +- warehouse/locale/ja/LC_MESSAGES/messages.json | 2 +- warehouse/locale/messages.pot | 6 +- .../locale/pt_BR/LC_MESSAGES/messages.json | 2 +- warehouse/locale/ru/LC_MESSAGES/messages.json | 2 +- warehouse/locale/uk/LC_MESSAGES/messages.json | 2 +- .../locale/zh_Hans/LC_MESSAGES/messages.json | 2 +- .../locale/zh_Hant/LC_MESSAGES/messages.json | 2 +- .../js/warehouse/utils/messages-access.js | 48 +- .../static/js/warehouse/utils/timeago.js | 8 +- warehouse/templates/base.html | 2 +- webpack.config.js | 486 +++++++++--------- webpack.plugin.localize.js | 114 +--- 26 files changed, 372 insertions(+), 534 deletions(-) delete mode 100644 bin/translations-js.mjs diff --git a/bin/translations-js.mjs b/bin/translations-js.mjs deleted file mode 100644 index 4b2b8a09044d..000000000000 --- a/bin/translations-js.mjs +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env node - -/* - based on: - - po2json wrapper for gettext.js https://github.com/mikeedwards/po2json - - based on https://github.com/guillaumepotier/gettext.js/blob/v2.0.2/bin/po2json - - Dump all .po files in one json file containing an array with entries like this one: - - { - "": { - "language": "en", - "plural-forms": "nplurals=2; plural=(n!=1);" - }, - "simple key": "It's tranlation", - "another with %1 parameter": "It's %1 tranlsation", - "a key with plural": [ - "a plural form", - "another plural form", - "could have up to 6 forms with some languages" - ], - "a context\u0004a contextualized key": "translation here" - } - -*/ - - -import {readdir, readFile, stat, writeFile} from "node:fs/promises"; -import {resolve} from "node:path"; -import {po} from "gettext-parser"; - - -const argv = process.argv; - -const runPo2Json = async function (filePath) { - const buffer = await readFile(filePath); - const jsonData = po.parse(buffer, {defaultCharset:"utf-8", validation: false }); - - // Build the format expected by gettext.js. - // Includes only translations from .js files. - // NOTE: This assumes there is only one msgctxt (the default ""), - // and that default msgid ("") contains the headers. - const translations = jsonData.translations[""]; - const jsonResult = { - "": { - "language": jsonData["headers"]["language"], - "plural-forms": jsonData["headers"]["plural-forms"], - } - }; - - for (const msgid in translations) { - if ("" === msgid) { - continue; - } - - const item = translations[msgid]; - - if (!item["comments"]["reference"].includes(".js:")) { - // ignore non-js translations - continue; - } - - const values = item["msgstr"]; - if (!values.some((value) => value.length > 0 && value !== msgid)) { - // only include if there are any translated strings - continue; - } - - if (item["msgstr"].length === 1) { - jsonResult[msgid] = values[0]; - } else { - jsonResult[msgid] = values; - } - } - - return jsonResult; -} - - -const recurseDir = async function recurseDir(dir) { - const result = []; - const files = await readdir(dir); - for (const file of files) { - const filePath = resolve(dir, file); - const fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - const results = await recurseDir(filePath); - result.push(...results); - } else if (filePath.endsWith("messages.po")) { - const item = await runPo2Json(filePath); - if (Object.keys(item).length > 1) { - // only include if there are translated strings - console.log(`found js messages in ${filePath}`); - result.push(await runPo2Json(filePath)); - } - } - } - return result; -}; - -const updateJsonTranslations = async function updateJsonTranslations(dir) { - const result = await recurseDir(dir); - const destFile = resolve("./warehouse/static/js/warehouse/utils/messages.json"); - const destData = JSON.stringify(result, null, 2) - await writeFile(destFile, destData); - console.log(`writing js messages to ${destFile}`); -} - - -updateJsonTranslations(argv[2]); diff --git a/bin/translations_json.py b/bin/translations_json.py index 1cf7ef944f1b..7bda45cf74f9 100644 --- a/bin/translations_json.py +++ b/bin/translations_json.py @@ -14,7 +14,7 @@ import json import pathlib - +import json import polib from warehouse.i18n import KNOWN_LOCALES @@ -47,21 +47,19 @@ # if one or more translation messages from javascript files were found, # then write the json file to the same folder. result = { - "locale": lang, - "plural-forms": po.metadata['Plural-Forms'], - "entries": {e.msgid: { - "flags": e.flags, - "msgctxt": e.msgctxt, - "msgid": e.msgid, - "msgid_plural": e.msgid_plural, - "msgid_with_context": e.msgid_with_context, - "msgstr": e.msgstr, - "msgstr_plural": e.msgstr_plural, - "prev_msgctxt": e.previous_msgctxt, - "prev_msgid": e.previous_msgid, - "prev_msgid_plural": e.previous_msgid_plural, - } for e in entries}, + "": { + "language": lang, + "plural-forms": po.metadata['Plural-Forms'], + } } + for e in entries: + if e.msgid_plural: + result[e.msgid] = list(e.msgstr_plural.values()) + elif e.msgstr: + result[e.msgid] = e.msgstr + else: + raise ValueError(f"No value available for ${e}") + json_path = po_path.with_suffix('.json') with json_path.open('w') as f: print(f"Writing messages to {json_path}") diff --git a/docs/dev/development/frontend.rst b/docs/dev/development/frontend.rst index fe90356a5118..ffd049acbd84 100644 --- a/docs/dev/development/frontend.rst +++ b/docs/dev/development/frontend.rst @@ -151,3 +151,22 @@ One of these blocks provides code syntax highlighting, which can be tested with reference project provided at ``_ when using development database. Source reStructuredText file is available `here `_. + + +Javascript localization support +------------------------------- + +Strings in JS can be translated, see the see the :doc:`../translations` docs. + +As part of the webpack build the translation data for each locale in ``KNOWN_LOCALES`` +is placed in |warehouse/static/js/warehouse/utils/messages-access.js|_. + +A separate js bundle is generated for each locale, named like this: ``warehouse.[locale].[contenthash].js``. + +The JS bundle to include is selected in |warehouse/templates/base.html|_ using the current :code:`request.localizer.locale_name`. + +.. |warehouse/static/js/warehouse/utils/messages-access.js| replace:: ``warehouse/static/js/warehouse/utils/messages-access.js`` +.. _warehouse/static/js/warehouse/utils/messages-access.js: https://github.com/pypi/warehouse/blob/main/warehouse/static/js/warehouse/utils/messages-access.js + +.. |warehouse/templates/base.html| replace:: ``warehouse/templates/base.html`` +.. _warehouse/templates/base.html: https://github.com/pypi/warehouse/blob/main/warehouse/templates/base.html diff --git a/docs/dev/development/reviewing-patches.rst b/docs/dev/development/reviewing-patches.rst index e1d5452f9db9..5c2b63f6ff45 100644 --- a/docs/dev/development/reviewing-patches.rst +++ b/docs/dev/development/reviewing-patches.rst @@ -148,8 +148,8 @@ Merge requirements backwards incompatible release of a dependency) no pull requests may be merged until this is rectified. * All merged patches must have 100% test coverage. -* All user facing strings must be marked for translation and the ``.pot`` and - ``.po`` files must be updated. +* All user facing strings must be marked for translation and the ``.pot``, + ``.po``, and ``.json`` files must be updated. .. _`excellent to one another`: https://speakerdeck.com/ohrite/better-code-review diff --git a/docs/dev/translations.rst b/docs/dev/translations.rst index 3d88cabccc1d..5dfaf8196161 100644 --- a/docs/dev/translations.rst +++ b/docs/dev/translations.rst @@ -62,12 +62,23 @@ In Python, given a request context, call :code:`request._(message)` to mark from warehouse.i18n import localize as _ message = _("Your message here.") -In javascript, +In javascript, use :code:`gettext("singular", ...placeholder_values)` and +:code:`ngettext("singular", "plural", count, ...placeholder_values)`. +The function names are important because they need to be recognised by pybabel. + +.. code-block:: javascript + + import { gettext, ngettext } from "../utils/messages-access"; + gettext("Get some fruit"); + // -> (en) "Get some fruit" + ngettext("Yesterday", "In the past", numDays); + // -> (en) numDays is 1: "Yesterday"; numDays is 3: "In the past" + Passing non-translatable values to translated strings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To pass values you don't want to be translated into +In html, to pass values you don't want to be translated into translated strings, define them inside the :code:`{% trans %}` tag. For example, to pass a non-translatable link :code:`request.route_path('classifiers')` into a string, instead of @@ -87,6 +98,14 @@ Instead, define it inside the :code:`{% trans %}` tag: Filter by classifier {% endtrans %} +In javascript, use :code:`%1`, :code:`%2`, etc as placeholders and provide the placeholder values: + +.. code-block:: javascript + + import { ngettext } from "../utils/messages-access"; + ngettext("Yesterday", "About %1 days ago", numDays, numDays); + // -> (en) numDays is 1: "Yesterday"; numDays is 3: "About 3 days ago" + Marking new strings for pluralization ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -106,6 +125,8 @@ variants of a string, for example: This is not yet directly possible in Python for Warehouse. +In javascript, use :code:`ngettext()` as described above. + Marking views as translatable ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/warehouse/locale/Makefile b/warehouse/locale/Makefile index 24f630c2bf8f..5ab7b708b35d 100644 --- a/warehouse/locale/Makefile +++ b/warehouse/locale/Makefile @@ -5,7 +5,7 @@ compile-pot: PYTHONPATH=$(PWD) pybabel extract \ -F babel.cfg \ --omit-header \ - --ignore-dirs=".* ._ dist" \ + --ignore-dirs='.*' --ignore-dirs='_*' --ignore-dirs='*dist*' \ --output="warehouse/locale/messages.pot" \ warehouse diff --git a/warehouse/locale/am/LC_MESSAGES/messages.po b/warehouse/locale/am/LC_MESSAGES/messages.po index c58a2991531c..b9c8d34557b5 100644 --- a/warehouse/locale/am/LC_MESSAGES/messages.po +++ b/warehouse/locale/am/LC_MESSAGES/messages.po @@ -22,16 +22,10 @@ msgstr "" "Generated-By: Babel 2.7.0\n" #: warehouse/views.py:142 -#: warehouse/views.js:142 msgid "" "You must verify your **primary** email address before you can perform this " "action." -msgstr "some translation" - -#: warehouse/admin/static/js/warehouse.js:107 -#: warehouse/static/js/warehouse/controllers/clipboard_controller.js:32 -msgid "Copied" -msgstr "Copied translation" +msgstr "" #: warehouse/views.py:158 msgid "" diff --git a/warehouse/locale/de/LC_MESSAGES/messages.json b/warehouse/locale/de/LC_MESSAGES/messages.json index ad71dcac1840..44b9c8d103d4 100644 --- a/warehouse/locale/de/LC_MESSAGES/messages.json +++ b/warehouse/locale/de/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "de", "plural-forms": "nplurals=2; plural=n != 1;", "entries": {}} \ No newline at end of file +{"": {"language": "de", "plural-forms": "nplurals=2; plural=n != 1;"}} \ No newline at end of file diff --git a/warehouse/locale/el/LC_MESSAGES/messages.json b/warehouse/locale/el/LC_MESSAGES/messages.json index 302860174217..fef8c1b01f5f 100644 --- a/warehouse/locale/el/LC_MESSAGES/messages.json +++ b/warehouse/locale/el/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "el", "plural-forms": "nplurals=2; plural=n != 1;", "entries": {}} \ No newline at end of file +{"": {"language": "el", "plural-forms": "nplurals=2; plural=n != 1;"}} \ No newline at end of file diff --git a/warehouse/locale/eo/LC_MESSAGES/messages.json b/warehouse/locale/eo/LC_MESSAGES/messages.json index 72867add5153..7ca1f407d100 100644 --- a/warehouse/locale/eo/LC_MESSAGES/messages.json +++ b/warehouse/locale/eo/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "eo", "plural-forms": "nplurals=2; plural=n != 1;", "entries": {}} \ No newline at end of file +{"": {"language": "eo", "plural-forms": "nplurals=2; plural=n != 1;"}} \ No newline at end of file diff --git a/warehouse/locale/es/LC_MESSAGES/messages.json b/warehouse/locale/es/LC_MESSAGES/messages.json index dd9a06ebdbb2..861361cc58d0 100644 --- a/warehouse/locale/es/LC_MESSAGES/messages.json +++ b/warehouse/locale/es/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "es", "plural-forms": "nplurals=2; plural=n != 1;", "entries": {}} \ No newline at end of file +{"": {"language": "es", "plural-forms": "nplurals=2; plural=n != 1;"}} \ No newline at end of file diff --git a/warehouse/locale/fr/LC_MESSAGES/messages.json b/warehouse/locale/fr/LC_MESSAGES/messages.json index 8a55afdec084..3f67519ef729 100644 --- a/warehouse/locale/fr/LC_MESSAGES/messages.json +++ b/warehouse/locale/fr/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "fr", "plural-forms": "nplurals=2; plural=n > 1;", "entries": {"Error while validating hashed password, disregard on development": {"flags": [], "msgctxt": null, "msgid": "Error while validating hashed password, disregard on development", "msgid_plural": "", "msgid_with_context": "Error while validating hashed password, disregard on development", "msgstr": "FR: Error while validating hashed password, disregard on development", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Passwords match": {"flags": [], "msgctxt": null, "msgid": "Passwords match", "msgid_plural": "", "msgid_with_context": "Passwords match", "msgstr": "FR: Passwords match", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Passwords do not match": {"flags": [], "msgctxt": null, "msgid": "Passwords do not match", "msgid_plural": "", "msgid_with_context": "Passwords do not match", "msgstr": "FR: Passwords do not match", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Password field is empty": {"flags": [], "msgctxt": null, "msgid": "Password field is empty", "msgid_plural": "", "msgid_with_context": "Password field is empty", "msgstr": "FR: Password field is empty", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Password is strong": {"flags": [], "msgctxt": null, "msgid": "Password is strong", "msgid_plural": "", "msgid_with_context": "Password is strong", "msgstr": "FR: Password is strong", "msgstr_plural": {}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "Yesterday": {"flags": [], "msgctxt": null, "msgid": "Yesterday", "msgid_plural": "About %1 days ago", "msgid_with_context": "Yesterday", "msgstr": "", "msgstr_plural": {"0": "FR: Yesterday", "1": "FR: About %1 days ago"}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "About an hour ago": {"flags": [], "msgctxt": null, "msgid": "About an hour ago", "msgid_plural": "About %1 hours ago", "msgid_with_context": "About an hour ago", "msgstr": "", "msgstr_plural": {"0": "FR: About an hour ago", "1": "FR: About %1 hours ago"}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}, "About a minute ago": {"flags": [], "msgctxt": null, "msgid": "About a minute ago", "msgid_plural": "About %1 minutes ago", "msgid_with_context": "About a minute ago", "msgstr": "", "msgstr_plural": {"0": "FR: About a minute ago", "1": "FR: About %1 minutes ago"}, "prev_msgctxt": null, "prev_msgid": null, "prev_msgid_plural": null}}} \ No newline at end of file +{"": {"language": "fr", "plural-forms": "nplurals=2; plural=n > 1;"}} \ No newline at end of file diff --git a/warehouse/locale/fr/LC_MESSAGES/messages.po b/warehouse/locale/fr/LC_MESSAGES/messages.po index a3449310ccc0..d44c48639832 100644 --- a/warehouse/locale/fr/LC_MESSAGES/messages.po +++ b/warehouse/locale/fr/LC_MESSAGES/messages.po @@ -12220,42 +12220,42 @@ msgstr[1] "Aucun résultat pour les filtres « %(filters)s »" #: warehouse/static/js/warehouse/controllers/password_breach_controller.js:48 msgid "Error while validating hashed password, disregard on development" -msgstr "FR: Error while validating hashed password, disregard on development" +msgstr "" #: warehouse/static/js/warehouse/controllers/password_match_controller.js:32 msgid "Passwords match" -msgstr "FR: Passwords match" +msgstr "" #: warehouse/static/js/warehouse/controllers/password_match_controller.js:38 msgid "Passwords do not match" -msgstr "FR: Passwords do not match" +msgstr "" #: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:27 #: warehouse/templates/base.html:30 msgid "Password field is empty" -msgstr "FR: Password field is empty" +msgstr "" #: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:43 msgid "Password is strong" -msgstr "FR: Password is strong" +msgstr "" #: warehouse/static/js/warehouse/utils/timeago.js:33 msgid "Yesterday" msgid_plural "About %1 days ago" -msgstr[0] "FR: Yesterday" -msgstr[1] "FR: About %1 days ago" +msgstr[0] "" +msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:37 msgid "About an hour ago" msgid_plural "About %1 hours ago" -msgstr[0] "FR: About an hour ago" -msgstr[1] "FR: About %1 hours ago" +msgstr[0] "" +msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:39 msgid "About a minute ago" msgid_plural "About %1 minutes ago" -msgstr[0] "FR: About a minute ago" -msgstr[1] "FR: About %1 minutes ago" +msgstr[0] "" +msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:41 msgid "Just now" diff --git a/warehouse/locale/he/LC_MESSAGES/messages.json b/warehouse/locale/he/LC_MESSAGES/messages.json index 89c8a99a7479..0c93f403d01d 100644 --- a/warehouse/locale/he/LC_MESSAGES/messages.json +++ b/warehouse/locale/he/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "he", "plural-forms": "nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3));", "entries": {}} \ No newline at end of file +{"": {"language": "he", "plural-forms": "nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3));"}} \ No newline at end of file diff --git a/warehouse/locale/ja/LC_MESSAGES/messages.json b/warehouse/locale/ja/LC_MESSAGES/messages.json index 50ccd95faa3e..64164b021b87 100644 --- a/warehouse/locale/ja/LC_MESSAGES/messages.json +++ b/warehouse/locale/ja/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "ja", "plural-forms": "nplurals=1; plural=0;", "entries": {}} \ No newline at end of file +{"": {"language": "ja", "plural-forms": "nplurals=1; plural=0;"}} \ No newline at end of file diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index f93dbb46b0e3..396ad7b7532a 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -737,19 +737,19 @@ msgstr "" #: warehouse/static/js/warehouse/utils/timeago.js:33 msgid "Yesterday" -msgid_plural "About ${numDays} days ago" +msgid_plural "About %1 days ago" msgstr[0] "" msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:37 msgid "About an hour ago" -msgid_plural "About ${numHours} hours ago" +msgid_plural "About %1 hours ago" msgstr[0] "" msgstr[1] "" #: warehouse/static/js/warehouse/utils/timeago.js:39 msgid "About a minute ago" -msgid_plural "About ${numMinutes} minutes ago" +msgid_plural "About %1 minutes ago" msgstr[0] "" msgstr[1] "" diff --git a/warehouse/locale/pt_BR/LC_MESSAGES/messages.json b/warehouse/locale/pt_BR/LC_MESSAGES/messages.json index 69594e7a2e9d..a595f43dfe1c 100644 --- a/warehouse/locale/pt_BR/LC_MESSAGES/messages.json +++ b/warehouse/locale/pt_BR/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "pt_BR", "plural-forms": "nplurals=2; plural=n > 1;", "entries": {}} \ No newline at end of file +{"": {"language": "pt_BR", "plural-forms": "nplurals=2; plural=n > 1;"}} \ No newline at end of file diff --git a/warehouse/locale/ru/LC_MESSAGES/messages.json b/warehouse/locale/ru/LC_MESSAGES/messages.json index bd7a9991f554..f1cd1e46203f 100644 --- a/warehouse/locale/ru/LC_MESSAGES/messages.json +++ b/warehouse/locale/ru/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "ru", "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "entries": {}} \ No newline at end of file +{"": {"language": "ru", "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"}} \ No newline at end of file diff --git a/warehouse/locale/uk/LC_MESSAGES/messages.json b/warehouse/locale/uk/LC_MESSAGES/messages.json index 4671c004ef82..9b82a4b0c9b5 100644 --- a/warehouse/locale/uk/LC_MESSAGES/messages.json +++ b/warehouse/locale/uk/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "uk", "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "entries": {}} \ No newline at end of file +{"": {"language": "uk", "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"}} \ No newline at end of file diff --git a/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json b/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json index c1b1c6168990..7bcba4927104 100644 --- a/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json +++ b/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "zh_Hans", "plural-forms": "nplurals=1; plural=0;", "entries": {}} \ No newline at end of file +{"": {"language": "zh_Hans", "plural-forms": "nplurals=1; plural=0;"}} \ No newline at end of file diff --git a/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json b/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json index 7bf19e0c69ec..123e81ab8334 100644 --- a/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json +++ b/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json @@ -1 +1 @@ -{"locale": "zh_Hant", "plural-forms": "nplurals=1; plural=0;", "entries": {}} \ No newline at end of file +{"": {"language": "zh_Hant", "plural-forms": "nplurals=1; plural=0;"}} \ No newline at end of file diff --git a/warehouse/static/js/warehouse/utils/messages-access.js b/warehouse/static/js/warehouse/utils/messages-access.js index 354c205c3515..d731b4045093 100644 --- a/warehouse/static/js/warehouse/utils/messages-access.js +++ b/warehouse/static/js/warehouse/utils/messages-access.js @@ -11,7 +11,13 @@ * limitations under the License. */ -const i18n = require("gettext.js/dist/gettext.cjs.js"); +import i18n from "gettext.js/dist/gettext.esm"; + +const i18nInst = i18n(); + +// the value for 'messagesAccessLocaleData' is set by webpack.plugin.localize.js +var messagesAccessLocaleData = {"": {"language": "en", "plural-forms": "nplurals = 2; plural = (n != 1)"}}; +i18nInst.loadJSON(messagesAccessLocaleData, "messages"); /** * Get the translation using num to choose the appropriate string. @@ -20,35 +26,24 @@ const i18n = require("gettext.js/dist/gettext.cjs.js"); * and have the translation strings extracted correctly. * Function 'ngettext' for plural extraction. * - * Any placeholders must be specified as + * Any placeholders must be specified as %1, %2, etc. * * @example - * import { gettext, ngettext } from "warehouse/utils/messages-access"; - * // For a singular only string: - * gettext("Just now"); + * import { ngettext } from "warehouse/utils/messages-access"; * // For a singular and plural and placeholder string: - * ngettext("About a minute ago", "About %1 minutes ago", numMinutes); + * ngettext("About a minute ago", "About %1 minutes ago", numMinutes, numMinutes); * @param singular {string} The default string for the singular translation. * @param plural {string} The default string for the plural translation. * @param num {number} The number to use to select the appropriate translation. * @param extras {string} Additional values to put in placeholders. * @returns {string} The translated text. + * @see https://github.com/guillaumepotier/gettext.js * @see https://www.gnu.org/software/gettext/manual/gettext.html#Language-specific-options * @see https://docs.pylonsproject.org/projects/pyramid/en/latest/api/i18n.html#pyramid.i18n.Localizer.pluralize */ export function ngettext(singular, plural, num, ...extras) { - const singularIsString = typeof singular === "string" || singular instanceof String; - if(singularIsString) { - // construct the translation using the fallback language (english) - i18n.setMessages("messages", "en", {[singular]:[singular, plural]}, "nplurals = 2; plural = (n != 1)"); - } else { - // After the webpack localizer processing, - // the non-string 'singular' is the translation data. - i18n.loadJSON(singular.data, "messages"); - } - - return i18n.ngettext(singular.singular, plural, num, ...extras); + return i18nInst.ngettext(singular, plural, num, ...extras); } /** @@ -58,22 +53,17 @@ export function ngettext(singular, plural, num, ...extras) { * and have the translation strings extracted correctly. * Function 'gettext' for singular extraction. * - * Any placeholders must be specified as + * Any placeholders must be specified as %1, %2, etc. + * + * @example + * import { gettext } from "warehouse/utils/messages-access"; + * // For a singular only string: + * gettext("Just now"); * * @param singular {string} The default string for the singular translation. * @param extras {string} Additional values to put in placeholders. * @returns {string} The translated text. */ export function gettext(singular, ...extras) { - const singularIsString = typeof singular === "string" || singular instanceof String; - if(singularIsString) { - // construct the translation using the fallback language (english) - i18n.setMessages("messages", "en", {[singular]:[singular]}, "nplurals = 2; plural = (n != 1)"); - } else { - // After the webpack localizer processing, - // the non-string 'singular' is the translation data. - i18n.loadJSON(singular.data, "messages"); - } - - return i18n.gettext(singular.singular, ...extras); + return i18nInst.gettext(singular, ...extras); } diff --git a/warehouse/static/js/warehouse/utils/timeago.js b/warehouse/static/js/warehouse/utils/timeago.js index b85c43fb953e..8b4c4319e140 100644 --- a/warehouse/static/js/warehouse/utils/timeago.js +++ b/warehouse/static/js/warehouse/utils/timeago.js @@ -30,15 +30,15 @@ const convertToReadableText = (time) => { let { numDays, numMinutes, numHours } = time; if (numDays >= 1) { - return ngettext("Yesterday", "About ${numDays} days ago", numDays); + return ngettext("Yesterday", "About %1 days ago", numDays, "another"); } if (numHours > 0) { - return ngettext("About an hour ago", "About ${numHours} hours ago", numHours); + return ngettext("About an hour ago", "About %1 hours ago", numHours); } else if (numMinutes > 0) { - return ngettext("About a minute ago", "About ${numMinutes} minutes ago", numMinutes); + return ngettext("About a minute ago", "About %1 minutes ago", numMinutes); } else { - return gettext("Just now"); + return gettext("Just now", "another"); } }; diff --git a/warehouse/templates/base.html b/warehouse/templates/base.html index 6eb35c592ce6..57252a89c9f3 100644 --- a/warehouse/templates/base.html +++ b/warehouse/templates/base.html @@ -131,7 +131,7 @@ {% if request.registry.settings.get("ga4.tracking_id") -%} data-ga4-id="{{ request.registry.settings['ga4.tracking_id'] }}" {% endif %} - src="{{ request.static_path('warehouse:static/dist/js/warehouse' + request.localizer.locale_name + '.js') }}"> + src="{{ request.static_path('warehouse:static/dist/js/warehouse' + ('' if request.localizer.locale_name == 'en' else '.' + request.localizer.locale_name) + '.js') }}"> {% block extra_js %} {% endblock %} diff --git a/webpack.config.js b/webpack.config.js index fbdc14c1b82d..17b39e4cb194 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,9 +15,7 @@ // See: https://webpack.js.org/configuration/ const path = require("path"); -const fs = require("fs"); const zlib = require("zlib"); -const glob = require("glob"); const rtlcss = require("rtlcss"); const CompressionPlugin = require("compression-webpack-plugin"); const CopyPlugin = require("copy-webpack-plugin"); @@ -28,7 +26,7 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const ProvidePlugin = require("webpack").ProvidePlugin; const RemoveEmptyScriptsPlugin = require("webpack-remove-empty-scripts"); const {WebpackManifestPlugin} = require("webpack-manifest-plugin"); -const {WebpackLocalisationPlugin, localeData} = require("./webpack.plugin.localize.js"); +const {WebpackLocalisationPlugin, allLocaleData} = require("./webpack.plugin.localize.js"); /* Shared Plugins */ @@ -93,24 +91,36 @@ const sharedResolve = { }, }; -// for each language locale, generate config for warehouse -const modulesLocales = localeData.map(function (data) { - const name = `warehouse.${data.locale}`; - return { - // stats: { - // errors: true, - // children: true, - // logging: "verbose", - // }, - name: name, + +module.exports = [ + { + name: "warehouse", + experiments: { + // allow us to manage RTL CSS as a separate file + layers: true, + }, plugins: [ - new WebpackLocalisationPlugin({localeData:data}), + new CopyPlugin({ + patterns: [ + { + // Most images are not referenced in JS/CSS, copy them manually. + from: path.resolve(__dirname, "warehouse/static/images/*"), + to: "images/[name].[contenthash][ext]", + }, + { + // Copy vendored zxcvbn code + from: path.resolve(__dirname, "warehouse/static/js/vendor/zxcvbn.js"), + to: "js/vendor/[name].[contenthash][ext]", + }, + ], + }), ...sharedCompressionPlugins, + ...sharedCSSPlugins, new WebpackManifestPlugin({ removeKeyHash: /([a-f0-9]{8}\.?)/gi, publicPath: "", seed: sharedWebpackManifestData, - map: sharedWebpackManifestMap, + map: sharedWebpackManifestMap }), new LiveReloadPlugin(), ], @@ -119,16 +129,33 @@ const modulesLocales = localeData.map(function (data) { // Webpack will create a bundle for each entry point. /* JavaScript */ - [name]: { + warehouse: { import: "./warehouse/static/js/warehouse/index.js", // override the filename from `index` to `warehouse` - filename: `js/${name}.[contenthash].js`, + filename: "js/warehouse.[contenthash].js", }, + + /* CSS */ + noscript: "./warehouse/static/sass/noscript.scss", + + // Default CSS + "warehouse-ltr": "./warehouse/static/sass/warehouse.scss", + // NOTE: Duplicate RTL CSS target. There's no clean way to generate both + // without duplicating the entry point right now. + "warehouse-rtl": { + import: "./warehouse/static/sass/warehouse.scss", + layer: "rtl", + }, + + /* Vendor Stuff */ + fontawesome: "./warehouse/static/sass/vendor/fontawesome.scss", }, // The default source map. Slowest, but best production-build optimizations. // See: https://webpack.js.org/configuration/devtool devtool: "source-map", output: { + // remove old files + clean: true, // Matches current behavior. Defaults to 20. 16 in the future. hashDigestLength: 8, // Global filename template for all assets. Other assets MUST override. @@ -136,267 +163,224 @@ const modulesLocales = localeData.map(function (data) { // Global output path for all assets. path: path.resolve(__dirname, "warehouse/static/dist"), }, - dependencies: ["warehouse"], - }; - -}); -const moduleWarehouse = { - // stats: { - // errors: true, - // children: true, - // logging: "verbose", - // }, - name: "warehouse", - experiments: { - // allow us to manage RTL CSS as a separate file - layers: true, - }, - plugins: [ - new CopyPlugin({ - patterns: [ + module: { + rules: [ { - // Most images are not referenced in JS/CSS, copy them manually. - from: path.resolve(__dirname, "warehouse/static/images/*"), - to: "images/[name].[contenthash][ext]", + // Handle SASS/SCSS/CSS files + test: /\.(sa|sc|c)ss$/, + // NOTE: Order is important here, as the first match wins + oneOf: [ + { + // For the `rtl` file, needs postcss processing + layer: "rtl", + issuerLayer: "rtl", + use: [ + MiniCssExtractPlugin.loader, + "css-loader", + { + loader: "postcss-loader", + options: { + postcssOptions: { + plugins: [rtlcss()], + }, + }, + }, + "sass-loader", + ], + }, + { + // All other CSS files + use: [ + // Extracts CSS into separate files + MiniCssExtractPlugin.loader, + // Translates CSS into CommonJS + "css-loader", + // Translate SCSS to CSS + "sass-loader", + ], + }, + ], }, { - // Copy vendored zxcvbn code - from: path.resolve(__dirname, "warehouse/static/js/vendor/zxcvbn.js"), - to: "js/vendor/[name].[contenthash][ext]", + // Handle image files + test: /\.(png|svg|jpg|jpeg|gif)$/i, + // disables data URL inline encoding images into CSS, + // since it violates our CSP settings. + type: "asset/resource", + generator: { + filename: "images/[name].[contenthash][ext]", + }, + }, + { + // Handle font files + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: "asset/resource", + generator: { + filename: "webfonts/[name].[contenthash][ext]", + }, }, ], - }), - ...sharedCompressionPlugins, - ...sharedCSSPlugins, - new WebpackManifestPlugin({ - removeKeyHash: /([a-f0-9]{8}\.?)/gi, - publicPath: "", - seed: sharedWebpackManifestData, - map: sharedWebpackManifestMap - }), - new LiveReloadPlugin(), - ], - resolve: sharedResolve, - entry: { - // Webpack will create a bundle for each entry point. - - /* JavaScript */ - warehouse: { - import: "./warehouse/static/js/warehouse/index.js", - // override the filename from `index` to `warehouse` - filename: "js/warehouse.[contenthash].js", - }, - - /* CSS */ - noscript: "./warehouse/static/sass/noscript.scss", - - // Default CSS - "warehouse-ltr": "./warehouse/static/sass/warehouse.scss", - // NOTE: Duplicate RTL CSS target. There's no clean way to generate both - // without duplicating the entry point right now. - "warehouse-rtl": { - import: "./warehouse/static/sass/warehouse.scss", - layer: "rtl", }, - - /* Vendor Stuff */ - fontawesome: "./warehouse/static/sass/vendor/fontawesome.scss", - }, - // The default source map. Slowest, but best production-build optimizations. - // See: https://webpack.js.org/configuration/devtool - devtool: "source-map", - output: { - // remove old files - clean: true, - // Matches current behavior. Defaults to 20. 16 in the future. - hashDigestLength: 8, - // Global filename template for all assets. Other assets MUST override. - filename: "[name].[contenthash].js", - // Global output path for all assets. - path: path.resolve(__dirname, "warehouse/static/dist"), - }, - module: { - rules: [ - { - // Handle SASS/SCSS/CSS files - test: /\.(sa|sc|c)ss$/, - // NOTE: Order is important here, as the first match wins - oneOf: [ - { - // For the `rtl` file, needs postcss processing - layer: "rtl", - issuerLayer: "rtl", - use: [ - MiniCssExtractPlugin.loader, - "css-loader", + optimization: { + minimizer: [ + // default minimizer is Terser for JS. Extend here vs overriding. + "...", + // Minimize CSS + new CssMinimizerPlugin({ + minimizerOptions: { + preset: [ + "default", { - loader: "postcss-loader", - options: { - postcssOptions: { - plugins: [rtlcss()], - }, - }, + discardComments: {removeAll: true}, }, - "sass-loader", ], }, - { - // All other CSS files - use: [ - // Extracts CSS into separate files - MiniCssExtractPlugin.loader, - // Translates CSS into CommonJS - "css-loader", - // Translate SCSS to CSS - "sass-loader", - ], + }), + // Minimize Images when `mode` is `production` + new ImageMinimizerPlugin({ + test: /\.(png|jpg|jpeg|gif)$/i, + minimizer: { + implementation: ImageMinimizerPlugin.sharpMinify, }, - ], - }, - { - // Handle image files - test: /\.(png|svg|jpg|jpeg|gif)$/i, - // disables data URL inline encoding images into CSS, - // since it violates our CSP settings. - type: "asset/resource", - generator: { - filename: "images/[name].[contenthash][ext]", - }, - }, - { - // Handle font files - test: /\.(woff|woff2|eot|ttf|otf)$/i, - type: "asset/resource", - generator: { - filename: "webfonts/[name].[contenthash][ext]", - }, - }, - ], - }, - optimization: { - minimizer: [ - // default minimizer is Terser for JS. Extend here vs overriding. - "...", - // Minimize CSS - new CssMinimizerPlugin({ - minimizerOptions: { - preset: [ - "default", + generator: [ { - discardComments: {removeAll: true}, + // Apply generator for copied assets + type: "asset", + implementation: ImageMinimizerPlugin.sharpGenerate, + options: { + encodeOptions: { + webp: { + quality: 90, + }, + }, + }, }, ], - }, - }), - // Minimize Images when `mode` is `production` - new ImageMinimizerPlugin({ - test: /\.(png|jpg|jpeg|gif)$/i, - minimizer: { - implementation: ImageMinimizerPlugin.sharpMinify, - }, - generator: [ - { - // Apply generator for copied assets - type: "asset", - implementation: ImageMinimizerPlugin.sharpGenerate, + }), + new ImageMinimizerPlugin({ + test: /\.(svg)$/i, + minimizer: { + implementation: ImageMinimizerPlugin.svgoMinify, options: { encodeOptions: { - webp: { - quality: 90, - }, + // Pass over SVGs multiple times to ensure all optimizations are applied. False by default + multipass: true, + plugins: [ + // set of built-in plugins enabled by default + // see: https://github.com/svg/svgo#default-preset + "preset-default", + ], }, }, }, - ], + }), + ], + }, + }, + { + name: "admin", + plugins: [ + ...sharedCompressionPlugins, + ...sharedCSSPlugins, + new WebpackManifestPlugin({ + removeKeyHash: /([a-f0-9]{8}\.?)/gi, + publicPath: "", + map: sharedWebpackManifestMap, }), - new ImageMinimizerPlugin({ - test: /\.(svg)$/i, - minimizer: { - implementation: ImageMinimizerPlugin.svgoMinify, - options: { - encodeOptions: { - // Pass over SVGs multiple times to ensure all optimizations are applied. False by default - multipass: true, - plugins: [ - // set of built-in plugins enabled by default - // see: https://github.com/svg/svgo#default-preset - "preset-default", - ], - }, - }, - }, + // admin site dependencies use jQuery + new ProvidePlugin({ + $: "jquery", + jQuery: "jquery", }), + new LiveReloadPlugin(), ], - }, -}; - -const moduleAdmin = { - name: "admin", - plugins: [ - ...sharedCompressionPlugins, - ...sharedCSSPlugins, - new WebpackManifestPlugin({ - removeKeyHash: /([a-f0-9]{8}\.?)/gi, - publicPath: "", - map: sharedWebpackManifestMap, - }), - // admin site dependencies use jQuery - new ProvidePlugin({ - $: "jquery", - jQuery: "jquery", - }), - new LiveReloadPlugin(), - ], - resolve: sharedResolve, - entry: { - admin: { - import: "./warehouse/admin/static/js/warehouse.js", - filename: "js/admin.[contenthash].js", + resolve: sharedResolve, + entry: { + admin: { + import: "./warehouse/admin/static/js/warehouse.js", + filename: "js/admin.[contenthash].js", + }, + all: { + import: "./warehouse/admin/static/css/admin.scss", + }, }, - all: { - import: "./warehouse/admin/static/css/admin.scss", + devtool: "source-map", + output: { + clean: true, + hashDigestLength: 8, + filename: "[name].[contenthash].js", + path: path.resolve(__dirname, "warehouse/admin/static/dist"), + }, + module: { + rules: [ + { + test: /\.(sa|sc|c)ss$/, + use: [ + MiniCssExtractPlugin.loader, + "css-loader", + "sass-loader", + ], + }, + { + // Handle image files + test: /\.(png|svg|jpg|jpeg|gif)$/i, + // disables data URL inline encoding images into CSS, + // since it violates our CSP settings. + type: "asset/resource", + generator: { + filename: "images/[name].[contenthash][ext]", + }, + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: "asset/resource", + generator: { + filename: "fonts/[name].[contenthash][ext]", + }, + }, + ], }, }, - devtool: "source-map", - output: { - clean: true, - hashDigestLength: 8, - filename: "[name].[contenthash].js", - path: path.resolve(__dirname, "warehouse/admin/static/dist"), - }, - module: { - rules: [ - { - test: /\.(sa|sc|c)ss$/, - use: [ - MiniCssExtractPlugin.loader, - "css-loader", - "sass-loader", - ], - }, - { - // Handle image files - test: /\.(png|svg|jpg|jpeg|gif)$/i, - // disables data URL inline encoding images into CSS, - // since it violates our CSP settings. - type: "asset/resource", - generator: { - filename: "images/[name].[contenthash][ext]", + // for each language locale, generate config for warehouse + ...allLocaleData.map(function (localeData) { + const name = `warehouse.${localeData[""].language}`; + return { + name: name, + plugins: [ + new WebpackLocalisationPlugin(localeData), + ...sharedCompressionPlugins, + new WebpackManifestPlugin({ + removeKeyHash: /([a-f0-9]{8}\.?)/gi, + publicPath: "", + seed: sharedWebpackManifestData, + map: sharedWebpackManifestMap, + }), + new LiveReloadPlugin(), + ], + resolve: sharedResolve, + entry: { + // Webpack will create a bundle for each entry point. + + /* JavaScript */ + [name]: { + import: "./warehouse/static/js/warehouse/index.js", + // override the filename from `index` to `warehouse` + filename: `js/${name}.[contenthash].js`, }, }, - { - test: /\.(woff|woff2|eot|ttf|otf)$/i, - type: "asset/resource", - generator: { - filename: "fonts/[name].[contenthash][ext]", - }, + // The default source map. Slowest, but best production-build optimizations. + // See: https://webpack.js.org/configuration/devtool + devtool: "source-map", + output: { + // Matches current behavior. Defaults to 20. 16 in the future. + hashDigestLength: 8, + // Global filename template for all assets. Other assets MUST override. + filename: "[name].[contenthash].js", + // Global output path for all assets. + path: path.resolve(__dirname, "warehouse/static/dist"), }, - ], - }, -}; + dependencies: ["warehouse"], + }; -module.exports = [ - moduleWarehouse, - moduleAdmin, - ...modulesLocales, + }) ]; diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index 57988f09a2d4..b04f2cb9c0e1 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -20,6 +20,9 @@ * The javascript functions are in `warehouse/static/js/warehouse/utils/messages-access.js`. * * Run 'make translations' to generate the 'messages.json' files for the KNOWN_LOCALES. + * Run `make static_pipeline` to generate the js bundle for each locale. + * + * Currently only for 'warehouse', but can be extended to 'admin' if needed. */ // ref: https://webpack.js.org/contribute/writing-a-plugin/ @@ -33,122 +36,61 @@ const {resolve} = require("node:path"); const path = require("path"); // load the locale translation data -const baseDir = path.resolve(__dirname, "warehouse/locale"); -const localeData = fs.readdirSync(baseDir) - .map((file) => resolve(baseDir, file, "LC_MESSAGES/messages.json")) +const baseDir = __dirname; +const localeDir = path.resolve(baseDir, "warehouse/locale"); +const allLocaleData = fs + .readdirSync(localeDir) + .map((file) => resolve(localeDir, file, "LC_MESSAGES/messages.json")) .filter((file) => { try { return fs.statSync(file).isFile(); } catch { + // ignore error } }) .map((file) => { - console.log(`Translations from ${path.relative(__dirname, file)}`); + console.log(`Translations from ${path.relative(baseDir, file)}`); return fs.readFileSync(file, "utf8"); }) .map((data) => JSON.parse(data)); -// TODO: don't allow changing the WebpackLocalisationPlugin.functions - just do what is needed -const defaultFunctionGetTextJs = function (data, singular) { - let value; - if (Object.hasOwn(data.entries, singular)) { - value = JSON.stringify({ - "singular": singular, - "data": { - "": { - "locale": data.locale, - "plural-forms": data["plural-forms"], - }, - [singular]: Object.entries(data.entries[singular].msgstr_plural).map((entry) => entry[1]), - }, - }); - } else { - value = `"${singular}"`; - } - return value; -}; -const defaultFunctionExtras = function (extras) { - let extrasString = ""; - if (extras.length > 0) { - extrasString = `, "${extras.join("\", \"")}"`; - } - return extrasString; -}; -const defaultFunctions = { - gettext: (data, singular, ...extras) => { - const value = defaultFunctionGetTextJs(data, singular); - const extrasString = defaultFunctionExtras(extras); - return `gettext(${value} ${extrasString})`; - }, - ngettext: (data, singular, plural, num, ...extras) => { - const value = defaultFunctionGetTextJs(data, singular); - const extrasString = defaultFunctionExtras(extras); - return `ngettext(${value}, "${plural}", ${num} ${extrasString})`; - }, -}; + +const pluginName = "WebpackLocalisationPlugin"; class WebpackLocalisationPlugin { - constructor(options) { - const opts = options || {}; - this.localeData = opts.localeData || {}; - this.functions = opts.functions || defaultFunctions; + constructor(localeData) { + this.localeData = localeData || {}; } apply(compiler) { - const pluginName = "WebpackLocalisationPlugin"; const self = this; + // TODO: how to replace one argument of a function, and keep everything else the same? + // create a handler for each factory.hooks.parser const handler = function (parser) { - // for each function name and processing function - Object.keys(self.functions).forEach(function (findFuncName) { - const pluginTagImport = Symbol(`${pluginName}-import-tag-${findFuncName}`); - const replacementFunction = self.functions[findFuncName]; - - // tag imports so can later hook into their usages - parser.hooks.importSpecifier.tap(pluginName, (statement, source, exportName, identifierName) => { - if (exportName === findFuncName && identifierName === findFuncName) { - parser.tagVariable(identifierName, pluginTagImport, {}); - return true; - } - }); - - // hook into calls of the tagged imported function - parser.hooks.call.for(pluginTagImport).tap(pluginName, expr => { - try { - // pass the appropriate information for each type of argument - // TODO: pass expr.arguments directly so the information is available - let replacementValue = replacementFunction(self.localeData, ...expr.arguments.map((argument) => { - if (argument.type === "Literal") { - return argument.value; - } else if (argument.type === "Identifier") { - return argument.name; - } else { - throw new Error(`Unknown argument type '${argument.type}'.`); - } - })); - const dep = new ConstDependency(replacementValue, expr.range); - dep.loc = expr.loc; - parser.state.current.addDependency(dep); - return true; - } catch (err) { - parser.state.module.errors.push(err); - } - }); - + parser.hooks.statement.tap(pluginName, (statement) => { + if (statement.type === "VariableDeclaration" && + statement.declarations.length === 1 && + statement.declarations[0].id.name === "messagesAccessLocaleData") { + const initData = statement.declarations[0].init; + const dep = new ConstDependency(JSON.stringify(self.localeData), initData.range); + dep.loc = initData.loc; + parser.state.current.addDependency(dep); + return true; + } }); }; - // place the hooks into the webpack compiler, factories + // place the handler into the hooks for the webpack compiler module factories compiler.hooks.normalModuleFactory.tap(pluginName, factory => { factory.hooks.parser.for("javascript/auto").tap(pluginName, handler); factory.hooks.parser.for("javascript/dynamic").tap(pluginName, handler); factory.hooks.parser.for("javascript/esm").tap(pluginName, handler); }); - } } module.exports.WebpackLocalisationPlugin = WebpackLocalisationPlugin; -module.exports.localeData = localeData; +module.exports.allLocaleData = allLocaleData; From 1bcda98e9d1c302b9da31f31a60aaa2b3e5a3277 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 21 Apr 2024 01:58:31 +1000 Subject: [PATCH 29/52] embed plural forms using webpack and determine translation string Removes gettext.js. --- package-lock.json | 78 +------------------ package.json | 1 - .../js/warehouse/utils/messages-access.js | 62 +++++++++++++-- webpack.plugin.localize.js | 24 +++++- 4 files changed, 80 insertions(+), 85 deletions(-) diff --git a/package-lock.json b/package-lock.json index 152933d0be70..e2b7bd74c6c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "css-loader": "^6.7.3", "css-minimizer-webpack-plugin": "^5.0.0", "eslint": "^8.36.0", - "gettext.js": "^2.0.2", "glob": "^10.2.2", "image-minimizer-webpack-plugin": "^3.8.1", "jest": "^29.5.0", @@ -6498,6 +6497,8 @@ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -7492,50 +7493,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gettext-parser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-2.0.0.tgz", - "integrity": "sha512-FDs/7XjNw58ToQwJFO7avZZbPecSYgw8PBYhd0An+4JtZSrSzKhEvTsVV2uqdO7VziWTOGSgLGD5YRPdsCjF7Q==", - "dev": true, - "dependencies": { - "encoding": "^0.1.12", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/gettext-to-messageformat": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/gettext-to-messageformat/-/gettext-to-messageformat-0.3.1.tgz", - "integrity": "sha512-UyqIL3Ul4NryU95Wome/qtlcuVIqgEWVIFw0zi7Lv14ACLXfaVDCbrjZ7o+3BZ7u+4NS1mP/2O1eXZoHCoas8g==", - "dev": true, - "dependencies": { - "gettext-parser": "^1.4.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gettext-to-messageformat/node_modules/gettext-parser": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-1.4.0.tgz", - "integrity": "sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA==", - "dev": true, - "dependencies": { - "encoding": "^0.1.12", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/gettext.js": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/gettext.js/-/gettext.js-2.0.2.tgz", - "integrity": "sha512-V5S0HCgOzHmTaaHUNMQr3xVPu6qLpv3j6K9Ygi41Pu6SRNNmWwvXCf/wQYXArznm4uZyZ1v8yrDZngxy6+kCyw==", - "dev": true, - "dependencies": { - "po2json": "^1.0.0-beta-3" - }, - "bin": { - "po2json-gettextjs": "bin/po2json" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -11721,37 +11678,6 @@ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" }, - "node_modules/po2json": { - "version": "1.0.0-beta-3", - "resolved": "https://registry.npmjs.org/po2json/-/po2json-1.0.0-beta-3.tgz", - "integrity": "sha512-taS8y6ZEGzPAs0rygW9CuUPY8C3Zgx6cBy31QXxG2JlWS3fLxj/kuD3cbIfXBg30PuYN7J5oyBa/TIRjyqFFtg==", - "dev": true, - "dependencies": { - "commander": "^6.0.0", - "gettext-parser": "2.0.0", - "gettext-to-messageformat": "0.3.1" - }, - "bin": { - "po2json": "bin/po2json" - }, - "engines": { - "node": ">=10.0" - }, - "peerDependencies": { - "commander": "^6.0.0", - "gettext-parser": "2.0.0", - "gettext-to-messageformat": "0.3.1" - } - }, - "node_modules/po2json/node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", diff --git a/package.json b/package.json index f0b900aebba3..41eace002f75 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "css-loader": "^6.7.3", "css-minimizer-webpack-plugin": "^5.0.0", "eslint": "^8.36.0", - "gettext.js": "^2.0.2", "glob": "^10.2.2", "image-minimizer-webpack-plugin": "^3.8.1", "jest": "^29.5.0", diff --git a/warehouse/static/js/warehouse/utils/messages-access.js b/warehouse/static/js/warehouse/utils/messages-access.js index d731b4045093..2f83a5b0c756 100644 --- a/warehouse/static/js/warehouse/utils/messages-access.js +++ b/warehouse/static/js/warehouse/utils/messages-access.js @@ -11,13 +11,18 @@ * limitations under the License. */ -import i18n from "gettext.js/dist/gettext.esm"; - -const i18nInst = i18n(); - // the value for 'messagesAccessLocaleData' is set by webpack.plugin.localize.js +// default is en var messagesAccessLocaleData = {"": {"language": "en", "plural-forms": "nplurals = 2; plural = (n != 1)"}}; -i18nInst.loadJSON(messagesAccessLocaleData, "messages"); + +// the value for 'messagesAccessPluralFormFunction' is set by webpack.plugin.localize.js +// default is en +var messagesAccessPluralFormFunction = function (n) { + let nplurals, plural; + nplurals = 2; + plural = (n != 1); + return {total: nplurals, index: ((nplurals > 1 && plural === true) ? 1 : (plural ? plural : 0))}; +}; /** * Get the translation using num to choose the appropriate string. @@ -43,7 +48,18 @@ i18nInst.loadJSON(messagesAccessLocaleData, "messages"); * @see https://docs.pylonsproject.org/projects/pyramid/en/latest/api/i18n.html#pyramid.i18n.Localizer.pluralize */ export function ngettext(singular, plural, num, ...extras) { - return i18nInst.ngettext(singular, plural, num, ...extras); + let result = singular; + const data = getTranslationData(singular); + + try { + const pluralForms = messagesAccessPluralFormFunction(num); + result = data[pluralForms.index]; + } catch (err) { + console.log(err); + } + + result = insertPlaceholderValues(result, extras); + return result; } /** @@ -65,5 +81,37 @@ export function ngettext(singular, plural, num, ...extras) { * @returns {string} The translated text. */ export function gettext(singular, ...extras) { - return i18nInst.gettext(singular, ...extras); + let result; + result = getTranslationData(singular); + result = insertPlaceholderValues(result, extras); + return result; +} + +function getTranslationData(value) { + if (!value) { + return ""; + } + try { + return messagesAccessLocaleData[value]; + } catch (err) { + console.log(err); + return value; + } +} + +function insertPlaceholderValues(value, extras) { + if (!value) { + return ""; + } + if (!extras) { + return value; + } + try { + extras.forEach((extra, index) => { + value = value.replaceAll(`%${index + 1}`, extra); + }); + } catch (err) { + console.log(err); + } + return value; } diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index b04f2cb9c0e1..aa79b0f10b5e 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -16,7 +16,7 @@ * This plugin generates one javascript bundle per locale. * It replaces the javascript translation function arguments with the locale-specific data. * - * gettext.js is then used to determine the translation to use and replace placeholders. + * The translation to use is then determined and placeholders are replaced with values. * The javascript functions are in `warehouse/static/js/warehouse/utils/messages-access.js`. * * Run 'make translations' to generate the 'messages.json' files for the KNOWN_LOCALES. @@ -79,6 +79,28 @@ class WebpackLocalisationPlugin { dep.loc = initData.loc; parser.state.current.addDependency(dep); return true; + + } else if (statement.type === "VariableDeclaration" && + statement.declarations.length === 1 && + statement.declarations[0].id.name === "messagesAccessPluralFormFunction") { + const initData = statement.declarations[0].init; + const pluralForms = self.localeData[""]["plural-forms"]; + const denied = new RegExp( + "[~`^$_\\[\\]{}\\\\'\"\\.#@\\f\\n\\r\\t\\v\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+"); + const required = new RegExp("^ *nplurals *= *[0-9]+ *; *plural *= *.+$"); + if (denied.test(pluralForms) || !required.test(pluralForms)) { + throw new Error(`Invalid plural forms for ${self.localeData[""].language}.`); + } + const newValue = `function (n) { + let nplurals, plural; + ${pluralForms} + return {total: nplurals, index: ((nplurals > 1 && plural === true) ? 1 : (plural ? plural : 0))}; +}`; + const dep = new ConstDependency(newValue, initData.range); + dep.loc = initData.loc; + parser.state.current.addDependency(dep); + return true; + } }); }; From ac16bc5082a6e14ebaf31388e5414af78521b740 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 21 Apr 2024 15:33:47 +1000 Subject: [PATCH 30/52] add tests for messages-access.js Fix timeago.js --- tests/frontend/messages_access_test.js | 109 ++++++++++++++++++ .../js/warehouse/utils/messages-access.js | 58 ++++------ .../static/js/warehouse/utils/timeago.js | 6 +- 3 files changed, 137 insertions(+), 36 deletions(-) create mode 100644 tests/frontend/messages_access_test.js diff --git a/tests/frontend/messages_access_test.js b/tests/frontend/messages_access_test.js new file mode 100644 index 000000000000..d6c4823f9390 --- /dev/null +++ b/tests/frontend/messages_access_test.js @@ -0,0 +1,109 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* global expect, describe, it */ + +import {gettext, ngettext, ngettextCustom} from "../../warehouse/static/js/warehouse/utils/messages-access"; + + +describe("messages access util", () => { + + describe("gettext with defaults", () => { + it("uses default singular when no translation is available", async () => { + const singular = "My default message."; + const result = gettext(singular); + expect(result).toEqual(singular); + }); + it("inserts placeholders into the default singular", async () => { + const singular = "My default message: %1"; + const extras = ["more message here"]; + const result = gettext(singular, ...extras); + expect(result).toEqual("My default message: more message here"); + }); + }); + + describe("ngettext with defaults", () => { + it("uses default singular when no translation is available", async () => { + const singular = "My default message."; + const plural = "My default messages."; + const num = 1; + const result = ngettext(singular, plural, num); + expect(result).toEqual(singular); + }); + it("inserts placeholders into the default singular", async () => { + const singular = "My %2 default %1 message."; + const plural = "My default messages."; + const num = 1; + const extras = ["more message here", "something else"]; + const result = ngettext(singular, plural, num, ...extras); + expect(result).toEqual("My something else default more message here message."); + }); + it("uses default plural when no translation is available", async () => { + const singular = "My default message."; + const plural = "My default messages."; + const num = 2; + const result = ngettext(singular, plural, num); + expect(result).toEqual(plural); + }); + it("inserts placeholders into the default plural", async () => { + const singular = "My %2 default %1 message."; + const plural = "My default plural messages %1 %2."; + const num = 2; + const extras = ["more message here", "something else"]; + const result = ngettext(singular, plural, num, ...extras); + expect(result).toEqual("My default plural messages more message here something else."); + }); + }); + + describe("with translation data", () => { + const data = { + "": {"language": "fr", "plural-forms": "nplurals=2; plural=n > 1;"}, + "My default message.": "My translated message.", + "My %2 message with placeholders %1.": "My translated %1 message with placeholders %2", + "My message with plurals": ["My translated message 1.", "My translated messages 2."], + "My message with plurals %1 again": ["My translated message 1 %1.", "My translated message 2 %1"], + }; + const pluralForms = function (n) { + let nplurals, plural; + nplurals = 2; plural = n > 1; + return {total: nplurals, index: ((nplurals > 1 && plural === true) ? 1 : (plural ? plural : 0))}; + }; + it("uses singular when translation is available", async () => { + const singular = "My default message."; + const result = ngettextCustom(singular, null, 1, [], data, pluralForms); + expect(result).toEqual("My translated message."); + }); + it("inserts placeholders into the singular translation", async () => { + const singular = "My %2 message with placeholders %1."; + const extras = ["more message here", "another"]; + const result = ngettextCustom(singular, null, 1, extras, data, pluralForms); + expect(result).toEqual("My translated more message here message with placeholders another"); + }); + it("uses plural when translation is available", async () => { + const singular = "My message with plurals"; + const plural = "My messages with plurals"; + const num = 2; + const extras = ["not used"]; + const result = ngettextCustom(singular, plural, num, extras, data, pluralForms); + expect(result).toEqual("My translated messages 2."); + }); + it("inserts placeholders into the plural translation", async () => { + const singular = "My message with plurals %1 again"; + const plural = "My messages with plurals %1 again"; + const num = 2; + const extras = ["more message here"]; + const result = ngettextCustom(singular, plural, num, extras, data, pluralForms); + expect(result).toEqual("My translated message 2 more message here"); + }); + }); +}); diff --git a/warehouse/static/js/warehouse/utils/messages-access.js b/warehouse/static/js/warehouse/utils/messages-access.js index 2f83a5b0c756..d8282d562266 100644 --- a/warehouse/static/js/warehouse/utils/messages-access.js +++ b/warehouse/static/js/warehouse/utils/messages-access.js @@ -19,8 +19,7 @@ var messagesAccessLocaleData = {"": {"language": "en", "plural-forms": "nplurals // default is en var messagesAccessPluralFormFunction = function (n) { let nplurals, plural; - nplurals = 2; - plural = (n != 1); + nplurals = 2; plural = (n != 1); return {total: nplurals, index: ((nplurals > 1 && plural === true) ? 1 : (plural ? plural : 0))}; }; @@ -48,18 +47,7 @@ var messagesAccessPluralFormFunction = function (n) { * @see https://docs.pylonsproject.org/projects/pyramid/en/latest/api/i18n.html#pyramid.i18n.Localizer.pluralize */ export function ngettext(singular, plural, num, ...extras) { - let result = singular; - const data = getTranslationData(singular); - - try { - const pluralForms = messagesAccessPluralFormFunction(num); - result = data[pluralForms.index]; - } catch (err) { - console.log(err); - } - - result = insertPlaceholderValues(result, extras); - return result; + return ngettextCustom(singular, plural, num, extras, messagesAccessLocaleData, messagesAccessPluralFormFunction); } /** @@ -81,20 +69,28 @@ export function ngettext(singular, plural, num, ...extras) { * @returns {string} The translated text. */ export function gettext(singular, ...extras) { - let result; - result = getTranslationData(singular); - result = insertPlaceholderValues(result, extras); - return result; + return ngettext(singular, null, 1, ...extras); } -function getTranslationData(value) { - if (!value) { +export function ngettextCustom(singular, plural, num, extras, data, pluralForms) { + // this function allows for testing + const pluralFormsData = pluralForms(num); + let value = getTranslationData(data, singular); + if (Array.isArray(value)) { + value = value[pluralFormsData.index]; + } else if (pluralFormsData.index > 0) { + value = plural; + } + return insertPlaceholderValues(value, extras); +} + +function getTranslationData(data, value) { + if (!value || !value.trim()) { return ""; } - try { - return messagesAccessLocaleData[value]; - } catch (err) { - console.log(err); + if (Object.hasOwn(data, value)) { + return data[value]; + } else { return value; } } @@ -103,15 +99,11 @@ function insertPlaceholderValues(value, extras) { if (!value) { return ""; } - if (!extras) { + if (!extras || extras.length < 1 || !value.includes("%")) { return value; } - try { - extras.forEach((extra, index) => { - value = value.replaceAll(`%${index + 1}`, extra); - }); - } catch (err) { - console.log(err); - } - return value; + return extras.reduce((accumulator, currentValue, currentIndex) => { + const regexp = new RegExp(`%${currentIndex + 1}\\b`, "gi"); + return accumulator.replaceAll(regexp, currentValue); + }, value); } diff --git a/warehouse/static/js/warehouse/utils/timeago.js b/warehouse/static/js/warehouse/utils/timeago.js index 8b4c4319e140..d34e411d06a2 100644 --- a/warehouse/static/js/warehouse/utils/timeago.js +++ b/warehouse/static/js/warehouse/utils/timeago.js @@ -30,13 +30,13 @@ const convertToReadableText = (time) => { let { numDays, numMinutes, numHours } = time; if (numDays >= 1) { - return ngettext("Yesterday", "About %1 days ago", numDays, "another"); + return ngettext("Yesterday", "About %1 days ago", numDays, numDays); } if (numHours > 0) { - return ngettext("About an hour ago", "About %1 hours ago", numHours); + return ngettext("About an hour ago", "About %1 hours ago", numHours, numHours); } else if (numMinutes > 0) { - return ngettext("About a minute ago", "About %1 minutes ago", numMinutes); + return ngettext("About a minute ago", "About %1 minutes ago", numMinutes, numMinutes); } else { return gettext("Just now", "another"); } From 6946b685185203792f321995f0a7536386f05db1 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sun, 21 Apr 2024 15:40:20 +1000 Subject: [PATCH 31/52] fix lint issues --- bin/translations_json.py | 3 +-- docs/dev/development/frontend.rst | 9 ++++++--- docs/dev/translations.rst | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bin/translations_json.py b/bin/translations_json.py index 7bda45cf74f9..2f43b3dd7389 100644 --- a/bin/translations_json.py +++ b/bin/translations_json.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json import pathlib import json import polib @@ -20,7 +19,7 @@ from warehouse.i18n import KNOWN_LOCALES """ - +Extracts the translations required in javascript to per-locale messages.json files. """ domain = "messages" diff --git a/docs/dev/development/frontend.rst b/docs/dev/development/frontend.rst index ffd049acbd84..755f3414e93e 100644 --- a/docs/dev/development/frontend.rst +++ b/docs/dev/development/frontend.rst @@ -158,12 +158,15 @@ Javascript localization support Strings in JS can be translated, see the see the :doc:`../translations` docs. -As part of the webpack build the translation data for each locale in ``KNOWN_LOCALES`` +As part of the webpack build, +the translation data for each locale in ``KNOWN_LOCALES`` is placed in |warehouse/static/js/warehouse/utils/messages-access.js|_. -A separate js bundle is generated for each locale, named like this: ``warehouse.[locale].[contenthash].js``. +A separate js bundle is generated for each locale, +named like this: ``warehouse.[locale].[contenthash].js``. -The JS bundle to include is selected in |warehouse/templates/base.html|_ using the current :code:`request.localizer.locale_name`. +The JS bundle to include is selected in |warehouse/templates/base.html|_ +using the current :code:`request.localizer.locale_name`. .. |warehouse/static/js/warehouse/utils/messages-access.js| replace:: ``warehouse/static/js/warehouse/utils/messages-access.js`` .. _warehouse/static/js/warehouse/utils/messages-access.js: https://github.com/pypi/warehouse/blob/main/warehouse/static/js/warehouse/utils/messages-access.js diff --git a/docs/dev/translations.rst b/docs/dev/translations.rst index 5dfaf8196161..619072ce1069 100644 --- a/docs/dev/translations.rst +++ b/docs/dev/translations.rst @@ -98,7 +98,8 @@ Instead, define it inside the :code:`{% trans %}` tag: Filter by classifier {% endtrans %} -In javascript, use :code:`%1`, :code:`%2`, etc as placeholders and provide the placeholder values: +In javascript, use :code:`%1`, :code:`%2`, etc as +placeholders and provide the placeholder values: .. code-block:: javascript From 791c41c374a337d6559aef0b59bdd0177c247a21 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sat, 29 Jun 2024 12:33:04 +1000 Subject: [PATCH 32/52] add removed dev dependency for js translations --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index e58a7be4062a..8015fdab9ec9 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -3,4 +3,4 @@ hupper>=1.9 pip-tools>=1.0 pyramid_debugtoolbar>=2.5 pip-api - +polib From 93b94478005ea2867100c4ec8fc1e1a15ca9bcc1 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sat, 29 Jun 2024 12:33:23 +1000 Subject: [PATCH 33/52] update translations --- warehouse/locale/messages.pot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index f3293c40481c..e7c90e6d698a 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -336,7 +336,7 @@ msgstr "" msgid "Removed trusted publisher for project " msgstr "" -#: warehouse/admin/static/js/warehouse.js:107 +#: warehouse/admin/static/js/warehouse.js:114 #: warehouse/static/js/warehouse/controllers/clipboard_controller.js:32 msgid "Copied" msgstr "" From 88e027ef8a727824142bf2b4d981b4f2e7537408 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Mon, 19 Aug 2024 21:53:57 +1000 Subject: [PATCH 34/52] build js translation data as part of static_pipeline webpack build This removes a step and allows translation updates (static_pipeline) to generate the js bundles. The messages.json files are not required when translation data is bundled like this. --- bin/translations | 1 - bin/translations_json.py | 65 --------- docs/dev/development/reviewing-patches.rst | 4 +- docs/dev/translations.rst | 4 +- package-lock.json | 130 ++++++++++++++++++ package.json | 1 + requirements/dev.txt | 3 +- warehouse/locale/de/LC_MESSAGES/messages.json | 1 - warehouse/locale/el/LC_MESSAGES/messages.json | 1 - warehouse/locale/eo/LC_MESSAGES/messages.json | 1 - warehouse/locale/es/LC_MESSAGES/messages.json | 1 - warehouse/locale/fr/LC_MESSAGES/messages.json | 1 - warehouse/locale/fr/LC_MESSAGES/messages.po | 43 ------ warehouse/locale/he/LC_MESSAGES/messages.json | 1 - warehouse/locale/ja/LC_MESSAGES/messages.json | 1 - warehouse/locale/messages.pot | 2 +- .../locale/pt_BR/LC_MESSAGES/messages.json | 1 - warehouse/locale/ru/LC_MESSAGES/messages.json | 1 - warehouse/locale/uk/LC_MESSAGES/messages.json | 1 - .../locale/zh_Hans/LC_MESSAGES/messages.json | 1 - .../locale/zh_Hant/LC_MESSAGES/messages.json | 1 - webpack.config.js | 5 +- webpack.plugin.localize.js | 78 ++++++++--- 23 files changed, 197 insertions(+), 151 deletions(-) delete mode 100644 bin/translations_json.py delete mode 100644 warehouse/locale/de/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/el/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/eo/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/es/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/fr/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/he/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/ja/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/pt_BR/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/ru/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/uk/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/zh_Hans/LC_MESSAGES/messages.json delete mode 100644 warehouse/locale/zh_Hant/LC_MESSAGES/messages.json diff --git a/bin/translations b/bin/translations index e8bb0a81c972..335d861b1c8c 100755 --- a/bin/translations +++ b/bin/translations @@ -11,4 +11,3 @@ export LANG="${ENCODING:-en_US.UTF-8}" set -x make -C warehouse/locale/ translations -python bin/translations_json.py diff --git a/bin/translations_json.py b/bin/translations_json.py deleted file mode 100644 index 2f43b3dd7389..000000000000 --- a/bin/translations_json.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 - -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import pathlib -import json -import polib - -from warehouse.i18n import KNOWN_LOCALES - -""" -Extracts the translations required in javascript to per-locale messages.json files. -""" - -domain = "messages" -localedir = "warehouse/locale" -languages = [locale for locale in KNOWN_LOCALES] -cwd = pathlib.Path().cwd() -print("\nCreating messages.json files\n") - -# look in each language file that is used by the app -for lang in languages: - # read the .po file to find any .js file messages - entries = [] - include_next = False - - po_path = cwd.joinpath(localedir, lang, 'LC_MESSAGES', 'messages.po') - if not po_path.exists(): - continue - po = polib.pofile(po_path) - for entry in po.translated_entries(): - occurs_in_js = any(o.endswith('.js') for o, _ in entry.occurrences) - if occurs_in_js: - entries.append(entry) - - # if one or more translation messages from javascript files were found, - # then write the json file to the same folder. - result = { - "": { - "language": lang, - "plural-forms": po.metadata['Plural-Forms'], - } - } - for e in entries: - if e.msgid_plural: - result[e.msgid] = list(e.msgstr_plural.values()) - elif e.msgstr: - result[e.msgid] = e.msgstr - else: - raise ValueError(f"No value available for ${e}") - - json_path = po_path.with_suffix('.json') - with json_path.open('w') as f: - print(f"Writing messages to {json_path}") - json.dump(result, f) diff --git a/docs/dev/development/reviewing-patches.rst b/docs/dev/development/reviewing-patches.rst index 5c2b63f6ff45..e1d5452f9db9 100644 --- a/docs/dev/development/reviewing-patches.rst +++ b/docs/dev/development/reviewing-patches.rst @@ -148,8 +148,8 @@ Merge requirements backwards incompatible release of a dependency) no pull requests may be merged until this is rectified. * All merged patches must have 100% test coverage. -* All user facing strings must be marked for translation and the ``.pot``, - ``.po``, and ``.json`` files must be updated. +* All user facing strings must be marked for translation and the ``.pot`` and + ``.po`` files must be updated. .. _`excellent to one another`: https://speakerdeck.com/ohrite/better-code-review diff --git a/docs/dev/translations.rst b/docs/dev/translations.rst index 51108e290172..9b1fa7ec883a 100644 --- a/docs/dev/translations.rst +++ b/docs/dev/translations.rst @@ -22,7 +22,7 @@ To add a new known locale: 1. Check for `outstanding Weblate pull requests `_ and merge them if so. 2. In a new branch for |pypi/warehouse|_, add the new language identifier to - ``KNOWN_LOCALES`` in |warehouse/i18n/__init__.py|_. + ``KNOWN_LOCALES`` in |warehouse/i18n/__init__.py|_ and |webpack.plugin.localize.js|_. The value is the locale code, and corresponds to a directory in ``warehouse/locale``. 3. Commit these changes and make a new pull request to |pypi/warehouse|_. @@ -45,6 +45,8 @@ To add a new known locale: .. _pypi/warehouse: https://github.com/pypi/warehouse .. |warehouse/i18n/__init__.py| replace:: ``warehouse/i18n/__init__.py`` .. _warehouse/i18n/__init__.py: https://github.com/pypi/warehouse/blob/main/warehouse/i18n/__init__.py +.. |webpack.plugin.localize.js| replace:: ``webpack.plugin.localize.js`` +.. _webpack.plugin.localize.js: https://github.com/pypi/warehouse/blob/main/webpack.plugin.localize.js .. |pypi/infra| replace:: ``pypi/infra`` .. _pypi/infra: https://github.com/pypi/infra diff --git a/package-lock.json b/package-lock.json index 746447804f78..7efca882fe9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", "eslint": "^8.36.0", + "gettext-parser": "^7.0.1", "glob": "^10.2.2", "image-minimizer-webpack-plugin": "^4.0.2", "jest": "^29.5.0", @@ -4178,6 +4179,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", @@ -5546,6 +5559,15 @@ "dev": true, "license": "MIT" }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/continuable-cache": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/continuable-cache/-/continuable-cache-0.3.1.tgz", @@ -6792,6 +6814,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -7410,6 +7441,15 @@ "resolved": "https://registry.npmjs.org/eve-raphael/-/eve-raphael-0.5.0.tgz", "integrity": "sha512-jrxnPsCGqng1UZuEp9DecX/AuSyAszATSjf4oEcRxvfxa1Oux4KkIPKBAAWWnpdwfARtr+Q0o9aPYWjsROD7ug==" }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -7865,6 +7905,87 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gettext-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-7.0.1.tgz", + "integrity": "sha512-LU+ieGH3L9HmKEArTlX816/iiAlyA0fx/n/QSeQpkAaH/+jxMk/5UtDkAzcVvW+KlY25/U+IE6dnfkJ8ynt8pQ==", + "dev": true, + "dependencies": { + "content-type": "^1.0.5", + "encoding": "^0.1.13", + "readable-stream": "^4.3.0", + "safe-buffer": "^5.2.1" + } + }, + "node_modules/gettext-parser/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/gettext-parser/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dev": true, + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/gettext-parser/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/gettext-parser/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -12962,6 +13083,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/package.json b/package.json index 5a4ef1222352..a537a9609ba5 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", "eslint": "^8.36.0", + "gettext-parser": "^7.0.1", "glob": "^10.2.2", "image-minimizer-webpack-plugin": "^4.0.2", "jest": "^29.5.0", diff --git a/requirements/dev.txt b/requirements/dev.txt index 804cc0099f7e..5e4f4656dc83 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -3,5 +3,4 @@ hupper>=1.9 pip-tools>=1.0 pyramid_debugtoolbar>=2.5 pip-api -polib -watchdog \ No newline at end of file +watchdog diff --git a/warehouse/locale/de/LC_MESSAGES/messages.json b/warehouse/locale/de/LC_MESSAGES/messages.json deleted file mode 100644 index 44b9c8d103d4..000000000000 --- a/warehouse/locale/de/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "de", "plural-forms": "nplurals=2; plural=n != 1;"}} \ No newline at end of file diff --git a/warehouse/locale/el/LC_MESSAGES/messages.json b/warehouse/locale/el/LC_MESSAGES/messages.json deleted file mode 100644 index fef8c1b01f5f..000000000000 --- a/warehouse/locale/el/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "el", "plural-forms": "nplurals=2; plural=n != 1;"}} \ No newline at end of file diff --git a/warehouse/locale/eo/LC_MESSAGES/messages.json b/warehouse/locale/eo/LC_MESSAGES/messages.json deleted file mode 100644 index 7ca1f407d100..000000000000 --- a/warehouse/locale/eo/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "eo", "plural-forms": "nplurals=2; plural=n != 1;"}} \ No newline at end of file diff --git a/warehouse/locale/es/LC_MESSAGES/messages.json b/warehouse/locale/es/LC_MESSAGES/messages.json deleted file mode 100644 index 861361cc58d0..000000000000 --- a/warehouse/locale/es/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "es", "plural-forms": "nplurals=2; plural=n != 1;"}} \ No newline at end of file diff --git a/warehouse/locale/fr/LC_MESSAGES/messages.json b/warehouse/locale/fr/LC_MESSAGES/messages.json deleted file mode 100644 index 3f67519ef729..000000000000 --- a/warehouse/locale/fr/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "fr", "plural-forms": "nplurals=2; plural=n > 1;"}} \ No newline at end of file diff --git a/warehouse/locale/fr/LC_MESSAGES/messages.po b/warehouse/locale/fr/LC_MESSAGES/messages.po index 0a40f5ccf8f3..baeb3c3eafcc 100644 --- a/warehouse/locale/fr/LC_MESSAGES/messages.po +++ b/warehouse/locale/fr/LC_MESSAGES/messages.po @@ -12255,46 +12255,3 @@ msgstr[1] "Aucun résultat pour les filtres « %(filters)s »" #~ msgid "%(num_users)s users" #~ msgstr "%(num_users)s utilisateurs" - -#: warehouse/static/js/warehouse/controllers/password_breach_controller.js:48 -msgid "Error while validating hashed password, disregard on development" -msgstr "" - -#: warehouse/static/js/warehouse/controllers/password_match_controller.js:32 -msgid "Passwords match" -msgstr "" - -#: warehouse/static/js/warehouse/controllers/password_match_controller.js:38 -msgid "Passwords do not match" -msgstr "" - -#: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:27 -#: warehouse/templates/base.html:30 -msgid "Password field is empty" -msgstr "" - -#: warehouse/static/js/warehouse/controllers/password_strength_gauge_controller.js:43 -msgid "Password is strong" -msgstr "" - -#: warehouse/static/js/warehouse/utils/timeago.js:33 -msgid "Yesterday" -msgid_plural "About %1 days ago" -msgstr[0] "" -msgstr[1] "" - -#: warehouse/static/js/warehouse/utils/timeago.js:37 -msgid "About an hour ago" -msgid_plural "About %1 hours ago" -msgstr[0] "" -msgstr[1] "" - -#: warehouse/static/js/warehouse/utils/timeago.js:39 -msgid "About a minute ago" -msgid_plural "About %1 minutes ago" -msgstr[0] "" -msgstr[1] "" - -#: warehouse/static/js/warehouse/utils/timeago.js:41 -msgid "Just now" -msgstr "" diff --git a/warehouse/locale/he/LC_MESSAGES/messages.json b/warehouse/locale/he/LC_MESSAGES/messages.json deleted file mode 100644 index 0c93f403d01d..000000000000 --- a/warehouse/locale/he/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "he", "plural-forms": "nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3));"}} \ No newline at end of file diff --git a/warehouse/locale/ja/LC_MESSAGES/messages.json b/warehouse/locale/ja/LC_MESSAGES/messages.json deleted file mode 100644 index 64164b021b87..000000000000 --- a/warehouse/locale/ja/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "ja", "plural-forms": "nplurals=1; plural=0;"}} \ No newline at end of file diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 9b5174e96bf5..643d18107a1e 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -336,7 +336,7 @@ msgstr "" msgid "Removed trusted publisher for project " msgstr "" -#: warehouse/admin/static/js/warehouse.js:114 +#: warehouse/admin/static/js/warehouse.js:117 #: warehouse/static/js/warehouse/controllers/clipboard_controller.js:32 msgid "Copied" msgstr "" diff --git a/warehouse/locale/pt_BR/LC_MESSAGES/messages.json b/warehouse/locale/pt_BR/LC_MESSAGES/messages.json deleted file mode 100644 index a595f43dfe1c..000000000000 --- a/warehouse/locale/pt_BR/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "pt_BR", "plural-forms": "nplurals=2; plural=n > 1;"}} \ No newline at end of file diff --git a/warehouse/locale/ru/LC_MESSAGES/messages.json b/warehouse/locale/ru/LC_MESSAGES/messages.json deleted file mode 100644 index f1cd1e46203f..000000000000 --- a/warehouse/locale/ru/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "ru", "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"}} \ No newline at end of file diff --git a/warehouse/locale/uk/LC_MESSAGES/messages.json b/warehouse/locale/uk/LC_MESSAGES/messages.json deleted file mode 100644 index 9b82a4b0c9b5..000000000000 --- a/warehouse/locale/uk/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "uk", "plural-forms": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"}} \ No newline at end of file diff --git a/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json b/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json deleted file mode 100644 index 7bcba4927104..000000000000 --- a/warehouse/locale/zh_Hans/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "zh_Hans", "plural-forms": "nplurals=1; plural=0;"}} \ No newline at end of file diff --git a/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json b/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json deleted file mode 100644 index 123e81ab8334..000000000000 --- a/warehouse/locale/zh_Hant/LC_MESSAGES/messages.json +++ /dev/null @@ -1 +0,0 @@ -{"": {"language": "zh_Hant", "plural-forms": "nplurals=1; plural=0;"}} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 17b39e4cb194..d7996a4abeba 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -120,7 +120,7 @@ module.exports = [ removeKeyHash: /([a-f0-9]{8}\.?)/gi, publicPath: "", seed: sharedWebpackManifestData, - map: sharedWebpackManifestMap + map: sharedWebpackManifestMap, }), new LiveReloadPlugin(), ], @@ -381,6 +381,5 @@ module.exports = [ }, dependencies: ["warehouse"], }; - - }) + }), ]; diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index aa79b0f10b5e..ab8e15df1c85 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -12,17 +12,14 @@ */ /* This is a webpack plugin. - * * This plugin generates one javascript bundle per locale. - * It replaces the javascript translation function arguments with the locale-specific data. * - * The translation to use is then determined and placeholders are replaced with values. + * It replaces the javascript translation function arguments with the locale-specific data. * The javascript functions are in `warehouse/static/js/warehouse/utils/messages-access.js`. * - * Run 'make translations' to generate the 'messages.json' files for the KNOWN_LOCALES. - * Run `make static_pipeline` to generate the js bundle for each locale. + * Run 'make translations' before webpack to extract the translatable text to gettext format files. * - * Currently only for 'warehouse', but can be extended to 'admin' if needed. + * TODO: Currently only for 'warehouse', but can be extended to 'admin' if needed. */ // ref: https://webpack.js.org/contribute/writing-a-plugin/ @@ -34,25 +31,64 @@ const ConstDependency = require("webpack/lib/dependencies/ConstDependency"); const fs = require("node:fs"); const {resolve} = require("node:path"); const path = require("path"); +const gettextParser = require("gettext-parser"); -// load the locale translation data +// generate and then load the locale translation data const baseDir = __dirname; const localeDir = path.resolve(baseDir, "warehouse/locale"); -const allLocaleData = fs - .readdirSync(localeDir) - .map((file) => resolve(localeDir, file, "LC_MESSAGES/messages.json")) - .filter((file) => { +const KNOWN_LOCALES = [ + "en", // English + "es", // Spanish + "fr", // French + "ja", // Japanese + "pt_BR", // Brazilian Portuguese + "uk", // Ukrainian + "el", // Greek + "de", // German + "zh_Hans", // Simplified Chinese + "zh_Hant", // Traditional Chinese + "ru", // Russian + "he", // Hebrew + "eo", // Esperanto +]; +const allLocaleData = KNOWN_LOCALES + .filter(langCode => langCode !== "en") + .map((langCode) => resolve(localeDir, langCode, "LC_MESSAGES/messages.po")) + .filter((file) => fs.statSync(file).isFile()) + .map((file) => ({path: path.relative(baseDir, file), data: fs.readFileSync(file, "utf8")})) + .map((data) => { try { - return fs.statSync(file).isFile(); - } catch { - // ignore error + const lines = data.data + .split("\n") + // gettext-parser does not support obsolete previous translations, + // so filter out those lines + // see: https://github.com/smhg/gettext-parser/issues/79 + .filter(line => !line.startsWith("#~|")) + .join("\n"); + const parsed = gettextParser.po.parse(lines); + const result = { + "": { + "language": parsed.headers["Language"], + "plural-forms": parsed.headers["Plural-Forms"], + }, + }; + const translations = parsed.translations[""]; + for (const key in translations) { + if (key === "") { + continue; + } + const value = translations[key]; + const refs = value.comments.reference.split("\n"); + if (refs.every(refLine => !refLine.includes(".js:"))) { + continue; + } + result[value.msgid] = value.msgstr; + } + return result; + } catch (e) { + throw new Error(`Could not parse file ${data.path}: ${e.message}\n${e}`); } - }) - .map((file) => { - console.log(`Translations from ${path.relative(baseDir, file)}`); - return fs.readFileSync(file, "utf8"); - }) - .map((data) => JSON.parse(data)); + }); const pluginName = "WebpackLocalisationPlugin"; @@ -89,7 +125,7 @@ class WebpackLocalisationPlugin { "[~`^$_\\[\\]{}\\\\'\"\\.#@\\f\\n\\r\\t\\v\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+"); const required = new RegExp("^ *nplurals *= *[0-9]+ *; *plural *= *.+$"); if (denied.test(pluralForms) || !required.test(pluralForms)) { - throw new Error(`Invalid plural forms for ${self.localeData[""].language}.`); + throw new Error(`Invalid plural forms for ${self.localeData[""].language}: ${pluralForms}`); } const newValue = `function (n) { let nplurals, plural; From 0f7196d844c25d6807889891a88183b8bef6eec9 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 21:45:14 +1000 Subject: [PATCH 35/52] address feedback: don't translate the admin UI --- warehouse/admin/static/js/warehouse.js | 5 +---- webpack.plugin.localize.js | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/warehouse/admin/static/js/warehouse.js b/warehouse/admin/static/js/warehouse.js index c973abb8e177..b85174db4031 100644 --- a/warehouse/admin/static/js/warehouse.js +++ b/warehouse/admin/static/js/warehouse.js @@ -28,7 +28,6 @@ import "admin-lte/plugins/datatables-rowgroup/js/rowGroup.bootstrap4"; // Import AdminLTE JS import "admin-lte/build/js/AdminLTE"; -import {gettext} from "warehouse/utils/messages-access"; // Get our timeago function import timeAgo from "warehouse/utils/timeago"; @@ -113,10 +112,8 @@ document.querySelectorAll(".copy-text").forEach(function (element) { $(element).tooltip("hide") .attr("data-original-title", "Click to copy!"); }, 1000); - - const copiedText = gettext("Copied"); $(element).tooltip("hide") - .attr("data-original-title", copiedText) + .attr("data-original-title", "Copied!") .tooltip("show"); navigator.clipboard.writeText(text); } diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index ab8e15df1c85..bbd639580336 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -18,8 +18,6 @@ * The javascript functions are in `warehouse/static/js/warehouse/utils/messages-access.js`. * * Run 'make translations' before webpack to extract the translatable text to gettext format files. - * - * TODO: Currently only for 'warehouse', but can be extended to 'admin' if needed. */ // ref: https://webpack.js.org/contribute/writing-a-plugin/ From 010fdd26fe0d522b337b731bfcac895da052befd Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 21:52:37 +1000 Subject: [PATCH 36/52] address feedback: don't translate the admin UI --- warehouse/locale/messages.pot | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 643d18107a1e..9d53e2af54fa 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -336,11 +336,6 @@ msgstr "" msgid "Removed trusted publisher for project " msgstr "" -#: warehouse/admin/static/js/warehouse.js:117 -#: warehouse/static/js/warehouse/controllers/clipboard_controller.js:32 -msgid "Copied" -msgstr "" - #: warehouse/admin/templates/admin/banners/preview.html:15 msgid "Banner Preview" msgstr "" @@ -714,6 +709,10 @@ msgstr "" msgid "Your report has been recorded. Thank you for your help." msgstr "" +#: warehouse/static/js/warehouse/controllers/clipboard_controller.js:32 +msgid "Copied" +msgstr "" + #: warehouse/static/js/warehouse/controllers/password_breach_controller.js:48 msgid "Error while validating hashed password, disregard on development" msgstr "" From 43d6be135b3c4be76830ed05baca1929718731c9 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 21:53:51 +1000 Subject: [PATCH 37/52] address feedback: fix make dependency order --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 694061c8c4ae..02e2e9b7db1d 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ tests: .state/docker-build-base static_tests: .state/docker-build-static docker compose run --rm static bin/static_tests $(T) $(TESTARGS) -static_pipeline: .state/docker-build-static +static_pipeline: translations .state/docker-build-static docker compose run --rm static bin/static_pipeline $(T) $(TESTARGS) reformat: .state/docker-build-base @@ -104,7 +104,7 @@ licenses: .state/docker-build-base deps: .state/docker-build-base docker compose run --rm base bin/deps -translations: .state/docker-build-base .state/docker-build-static +translations: .state/docker-build-base docker compose run --rm base bin/translations requirements/%.txt: requirements/%.in From d32b4472ebf326109af61e0cce90e2f7015589c0 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 21:57:56 +1000 Subject: [PATCH 38/52] address feedback: improve docs for placeholder js variables Using ngettextCustom makes it possible to test the functions and use the function signatured required by pybabel. Fix eslint issues. --- .../js/warehouse/utils/messages-access.js | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/warehouse/static/js/warehouse/utils/messages-access.js b/warehouse/static/js/warehouse/utils/messages-access.js index d8282d562266..978b156bda4c 100644 --- a/warehouse/static/js/warehouse/utils/messages-access.js +++ b/warehouse/static/js/warehouse/utils/messages-access.js @@ -11,13 +11,15 @@ * limitations under the License. */ -// the value for 'messagesAccessLocaleData' is set by webpack.plugin.localize.js -// default is en -var messagesAccessLocaleData = {"": {"language": "en", "plural-forms": "nplurals = 2; plural = (n != 1)"}}; +// The value for 'messagesAccessLocaleData' is replaced by webpack.plugin.localize.js. +// The variable name must match the name used in webpack.plugin.localize.js. +// Default is 'en'. +const messagesAccessLocaleData = {"": {"language": "en", "plural-forms": "nplurals = 2; plural = (n != 1)"}}; -// the value for 'messagesAccessPluralFormFunction' is set by webpack.plugin.localize.js -// default is en -var messagesAccessPluralFormFunction = function (n) { +// The value for 'messagesAccessPluralFormFunction' is replaced by webpack.plugin.localize.js. +// The variable name must match the name used in webpack.plugin.localize.js. +// Default is 'en'. +const messagesAccessPluralFormFunction = function (n) { let nplurals, plural; nplurals = 2; plural = (n != 1); return {total: nplurals, index: ((nplurals > 1 && plural === true) ? 1 : (plural ? plural : 0))}; @@ -38,7 +40,7 @@ var messagesAccessPluralFormFunction = function (n) { * ngettext("About a minute ago", "About %1 minutes ago", numMinutes, numMinutes); * @param singular {string} The default string for the singular translation. - * @param plural {string} The default string for the plural translation. + * @param plural {string|null} The default string for the plural translation. * @param num {number} The number to use to select the appropriate translation. * @param extras {string} Additional values to put in placeholders. * @returns {string} The translated text. @@ -69,11 +71,22 @@ export function ngettext(singular, plural, num, ...extras) { * @returns {string} The translated text. */ export function gettext(singular, ...extras) { - return ngettext(singular, null, 1, ...extras); + return ngettextCustom(singular, null, 1, extras, messagesAccessLocaleData, messagesAccessPluralFormFunction); } +/** + * Get the translation. + * @param singular {string} The default string for the singular translation. + * @param plural {string|null} The default string for the plural translation. + * @param num {number} The number to use to select the appropriate translation. + * @param extras {string[]} Additional values to put in placeholders. + * @param data {{}} The locale data used for translation. + * @param pluralForms The function that calculates the plural form. + * @returns {string} The translated text. + */ export function ngettextCustom(singular, plural, num, extras, data, pluralForms) { - // this function allows for testing + // This function allows for testing and + // allows ngettext and gettext to have the signatures required by pybabel. const pluralFormsData = pluralForms(num); let value = getTranslationData(data, singular); if (Array.isArray(value)) { @@ -84,6 +97,12 @@ export function ngettextCustom(singular, plural, num, extras, data, pluralForms) return insertPlaceholderValues(value, extras); } +/** + * Get translation data safely. + * @param data {{}} The locale data used for translation. + * @param value {string} The default string for the singular translation, used as the key. + * @returns {string|string[]} + */ function getTranslationData(data, value) { if (!value || !value.trim()) { return ""; @@ -95,6 +114,12 @@ function getTranslationData(data, value) { } } +/** + * Insert placeholder values into a string. + * @param value {string} The translated string that might have placeholder values. + * @param extras {string[]} Additional values to put in placeholders. + * @returns {string} + */ function insertPlaceholderValues(value, extras) { if (!value) { return ""; From 7d1e8cbeb8b1123ddc241a852b5235bc4956039c Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 22:01:24 +1000 Subject: [PATCH 39/52] address feedback: add missing webpack js files to eslint paths --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a537a9609ba5..4e246995d4b0 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "build": "webpack", "watch": "webpack --watch", - "lint": "eslint 'warehouse/static/js/**' 'warehouse/admin/static/js/**' 'tests/frontend/**' --ignore-pattern 'warehouse/static/js/vendor/**'", - "lint:fix": "eslint 'warehouse/static/js/**' 'warehouse/admin/static/js/**' 'tests/frontend/**' --ignore-pattern 'warehouse/static/js/vendor/**' --fix", + "lint": "eslint 'warehouse/static/js/**' 'warehouse/admin/static/js/**' 'tests/frontend/**' 'webpack.*.js' --ignore-pattern 'warehouse/static/js/vendor/**'", + "lint:fix": "eslint 'warehouse/static/js/**' 'warehouse/admin/static/js/**' 'tests/frontend/**' 'webpack.*.js' --ignore-pattern 'warehouse/static/js/vendor/**' --fix", "stylelint": "stylelint '**/*.scss' --cache", "stylelint:fix": "stylelint '**/*.scss' --cache --fix", "test": "jest --coverage" From 34ed5e6402eb0a15bd0eed87478d9495eac24f0d Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 22:01:58 +1000 Subject: [PATCH 40/52] address feedback: fix eslint issues --- webpack.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webpack.config.js b/webpack.config.js index d7996a4abeba..a246dab32d2d 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,6 +11,8 @@ * limitations under the License. */ +/* global module, __dirname */ + // This is the main configuration file for webpack. // See: https://webpack.js.org/configuration/ From 0f13ec1e6da34b26e9bc7a97c9598aff2c9adae3 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 22:02:54 +1000 Subject: [PATCH 41/52] address feedback: extract shared value to constant --- webpack.config.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index a246dab32d2d..b833270d55d1 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -63,6 +63,9 @@ const sharedCSSPlugins = [ new RemoveEmptyScriptsPlugin(), ]; + +// Refs: https://github.com/shellscape/webpack-manifest-plugin/issues/229#issuecomment-737617994 +const sharedWebpackManifestPublicPath = ""; const sharedWebpackManifestData = {}; const sharedWebpackManifestMap = // Replace each entry with a prefix of a subdirectory. @@ -81,8 +84,6 @@ const sharedWebpackManifestMap = } return file; }; -// Refs: https://github.com/shellscape/webpack-manifest-plugin/issues/229#issuecomment-737617994 -// publicPath: "", /* End Shared Plugins */ @@ -120,7 +121,7 @@ module.exports = [ ...sharedCSSPlugins, new WebpackManifestPlugin({ removeKeyHash: /([a-f0-9]{8}\.?)/gi, - publicPath: "", + publicPath: sharedWebpackManifestPublicPath, seed: sharedWebpackManifestData, map: sharedWebpackManifestMap, }), @@ -286,7 +287,7 @@ module.exports = [ ...sharedCSSPlugins, new WebpackManifestPlugin({ removeKeyHash: /([a-f0-9]{8}\.?)/gi, - publicPath: "", + publicPath: sharedWebpackManifestPublicPath, map: sharedWebpackManifestMap, }), // admin site dependencies use jQuery @@ -353,7 +354,7 @@ module.exports = [ ...sharedCompressionPlugins, new WebpackManifestPlugin({ removeKeyHash: /([a-f0-9]{8}\.?)/gi, - publicPath: "", + publicPath: sharedWebpackManifestPublicPath, seed: sharedWebpackManifestData, map: sharedWebpackManifestMap, }), From b6fc1e6da1f80cbfde71bdf7a0ca0a45bc046642 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 22:04:10 +1000 Subject: [PATCH 42/52] address feedback: use jest each for tests This makes it easier to see the test cases. Add comments to make it clearer what the data is used for. --- tests/frontend/messages_access_test.js | 257 +++++++++++++++++++------ 1 file changed, 193 insertions(+), 64 deletions(-) diff --git a/tests/frontend/messages_access_test.js b/tests/frontend/messages_access_test.js index d6c4823f9390..047754f09549 100644 --- a/tests/frontend/messages_access_test.js +++ b/tests/frontend/messages_access_test.js @@ -19,91 +19,220 @@ import {gettext, ngettext, ngettextCustom} from "../../warehouse/static/js/wareh describe("messages access util", () => { describe("gettext with defaults", () => { - it("uses default singular when no translation is available", async () => { - const singular = "My default message."; - const result = gettext(singular); - expect(result).toEqual(singular); - }); - it("inserts placeholders into the default singular", async () => { - const singular = "My default message: %1"; - const extras = ["more message here"]; + it.each([ + // uses default singular when no translation is available + { + singular: "My default message.", + extras: [], + expected: "My default message.", + }, + // inserts placeholders into the default singular + { + singular: "My default message: %1", + extras: ["more message here"], + expected: "My default message: more message here", + }, + ])("translates $singular $extras to '$expected'", async ( + {singular, extras, expected} + ) => { const result = gettext(singular, ...extras); - expect(result).toEqual("My default message: more message here"); + expect(result).toEqual(expected); }); }); describe("ngettext with defaults", () => { - it("uses default singular when no translation is available", async () => { - const singular = "My default message."; - const plural = "My default messages."; - const num = 1; - const result = ngettext(singular, plural, num); - expect(result).toEqual(singular); - }); - it("inserts placeholders into the default singular", async () => { - const singular = "My %2 default %1 message."; - const plural = "My default messages."; - const num = 1; - const extras = ["more message here", "something else"]; - const result = ngettext(singular, plural, num, ...extras); - expect(result).toEqual("My something else default more message here message."); - }); - it("uses default plural when no translation is available", async () => { - const singular = "My default message."; - const plural = "My default messages."; - const num = 2; - const result = ngettext(singular, plural, num); - expect(result).toEqual(plural); - }); - it("inserts placeholders into the default plural", async () => { - const singular = "My %2 default %1 message."; - const plural = "My default plural messages %1 %2."; - const num = 2; - const extras = ["more message here", "something else"]; + it.each([ + // uses default singular when no translation is available + { + singular: "My default message.", + plural: "My default messages.", + num: 1, + extras: [], + expected: "My default message.", + }, + // inserts placeholders into the default singular + { + singular: "My %2 default %1 message.", + plural: "My default messages.", + num: 1, + extras: ["more message here", "something else"], + expected: "My something else default more message here message.", + }, + // uses default plural when no translation is available + { + singular: "My default message.", + plural: "My default messages.", + num: 2, + extras: [], + expected: "My default messages.", + }, + // inserts placeholders into the default plural + { + singular: "My %2 default %1 message.", + plural: "My default plural messages %1 %2.", + num: 2, + extras: ["more message here", "something else"], + expected: "My default plural messages more message here something else.", + }, + ])("translates $singular $plural $num $extras to '$expected'", async ( + {singular, plural, num, extras, expected} + ) => { const result = ngettext(singular, plural, num, ...extras); - expect(result).toEqual("My default plural messages more message here something else."); + expect(result).toEqual(expected); }); }); - describe("with translation data", () => { + describe("with data 'en' having more than one plural form", () => { + // This is the locale data, as would be embedded into + // messages-access.js by webpack.plugin.localize.js. const data = { - "": {"language": "fr", "plural-forms": "nplurals=2; plural=n > 1;"}, + "": {"language": "en", "plural-forms": "nplurals = 2; plural = (n != 1);"}, "My default message.": "My translated message.", "My %2 message with placeholders %1.": "My translated %1 message with placeholders %2", "My message with plurals": ["My translated message 1.", "My translated messages 2."], - "My message with plurals %1 again": ["My translated message 1 %1.", "My translated message 2 %1"], + "My message with plurals %1 again": ["My translated message 1 %1.", "My translated message 2 %1."], }; + // This is the plural form function, as would be embedded into + // messages-access.js by webpack.plugin.localize.js. + // The eslint rules are disabled here because the pluralForms function is + // generated by webpack.plugin.localize.js and this test must match a generated function. + /* eslint-disable */ const pluralForms = function (n) { let nplurals, plural; - nplurals = 2; plural = n > 1; + nplurals = 2; plural = (n != 1); return {total: nplurals, index: ((nplurals > 1 && plural === true) ? 1 : (plural ? plural : 0))}; }; - it("uses singular when translation is available", async () => { - const singular = "My default message."; - const result = ngettextCustom(singular, null, 1, [], data, pluralForms); - expect(result).toEqual("My translated message."); - }); - it("inserts placeholders into the singular translation", async () => { - const singular = "My %2 message with placeholders %1."; - const extras = ["more message here", "another"]; - const result = ngettextCustom(singular, null, 1, extras, data, pluralForms); - expect(result).toEqual("My translated more message here message with placeholders another"); - }); - it("uses plural when translation is available", async () => { - const singular = "My message with plurals"; - const plural = "My messages with plurals"; - const num = 2; - const extras = ["not used"]; + /* eslint-enable */ + it.each([ + // has invalid singular empty + { + singular: "", + plural: null, + num: 1, + extras: [], + expected: "", + }, + // has invalid singular whitespace only + { + singular: " ", + plural: null, + num: 1, + extras: [], + expected: "", + }, + // uses singular when translation is available + { + singular: "My default message.", + plural: null, + num: 1, + extras: [], + expected: "My translated message.", + }, + // inserts placeholders into the singular translation + { + singular: "My %2 message with placeholders %1.", + plural: null, + num: 1, + extras: ["str A", "strB"], + expected: "My translated str A message with placeholders strB", + }, + // uses plural when translation is available + { + singular: "My message with plurals", + plural: "My messages with plurals", + num: 2, extras: ["not used"], + expected: "My translated messages 2.", + }, + // inserts placeholders into the plural translation + { + singular: "My message with plurals %1 again", + plural: "My messages with plurals %1 again", + num: 2, + extras: ["waves"], + expected: "My translated message 2 waves.", + }, + ])("translates $singular $plural $num $extras to '$expected'", async ( + {singular, plural, num, extras, expected} + ) => { const result = ngettextCustom(singular, plural, num, extras, data, pluralForms); - expect(result).toEqual("My translated messages 2."); + expect(result).toEqual(expected); }); - it("inserts placeholders into the plural translation", async () => { - const singular = "My message with plurals %1 again"; - const plural = "My messages with plurals %1 again"; - const num = 2; - const extras = ["more message here"]; + }); + + describe("with custom data having only one plural form", () => { + // This is the locale data, as would be embedded into + // messages-access.js by webpack.plugin.localize.js. + const data = { + "": {"language": "id", "plural-forms": "nplurals = 1; plural = 0;"}, + "My default message.": "My translated message.", + "My %2 message with placeholders %1.": "My translated %1 message with placeholders %2", + "My message with plurals": ["My translated message 1.", "My translated messages 2."], + "My message with plurals %1 again": ["My translated message 1 %1.", "My translated message 2 %1"], + }; + // This is the plural form function, as would be embedded into + // messages-access.js by webpack.plugin.localize.js. + // The eslint rules are disabled here because the pluralForms function is + // generated by webpack.plugin.localize.js and this test must match a generated function. + /* eslint-disable */ + const pluralForms = function (n) { // eslint-disable-line no-unused-vars + let nplurals, plural; + nplurals = 1; plural = 0; + return {total: nplurals, index: ((nplurals > 1 && plural === true) ? 1 : (plural ? plural : 0))}; + }; + /* eslint-enable */ + it.each([ + // has invalid singular empty + { + singular: "", + plural: null, + num: 1, + extras: [], + expected: "", + }, + // has invalid singular whitespace only + { + singular: " ", + plural: null, + num: 1, + extras: [], + expected: "", + }, + // uses singular when translation is available + { + singular: "My default message.", + plural: null, + num: 1, + extras: [], + expected: "My translated message.", + }, + // inserts placeholders into the singular translation + { + singular: "My %2 message with placeholders %1.", + plural: null, + num: 1, + extras: ["str A", "strB"], + expected: "My translated str A message with placeholders strB", + }, + // uses first plural when translation is available + { + singular: "My message with plurals", + plural: "My messages with plurals", + num: 2, extras: ["not used"], + expected: "My translated message 1.", + }, + // inserts placeholders into the first plural translation + { + singular: "My message with plurals %1 again", + plural: "My messages with plurals %1 again", + num: 2, + extras: ["waves"], + expected: "My translated message 1 waves.", + }, + ])("translates $singular $plural $num $extras to '$expected'", async ( + {singular, plural, num, extras, expected} + ) => { const result = ngettextCustom(singular, plural, num, extras, data, pluralForms); - expect(result).toEqual("My translated message 2 more message here"); + expect(result).toEqual(expected); }); }); }); From 6b094a2b7d5bca6b073f25e17517fc579fc6aa70 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 22:04:41 +1000 Subject: [PATCH 43/52] address feedback: remove old todo --- webpack.plugin.localize.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index bbd639580336..9e4393c0cd8e 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -99,8 +99,6 @@ class WebpackLocalisationPlugin { apply(compiler) { const self = this; - // TODO: how to replace one argument of a function, and keep everything else the same? - // create a handler for each factory.hooks.parser const handler = function (parser) { From 70f2c33e57d4ef42f5a8f540865e040845d8deb0 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 22:04:58 +1000 Subject: [PATCH 44/52] address feedback: fix eslint issues --- webpack.plugin.localize.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index 9e4393c0cd8e..4637c80fbeda 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -25,6 +25,8 @@ // ref: https://github.com/webpack/webpack/discussions/14956 // ref: https://github.com/webpack/webpack/issues/9992 +/* global module, __dirname */ + const ConstDependency = require("webpack/lib/dependencies/ConstDependency"); const fs = require("node:fs"); const {resolve} = require("node:path"); From 8e2e3f56848086c55c90190bc63353546c0e9e03 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 22:11:21 +1000 Subject: [PATCH 45/52] address feedback: improve plural forms pattern and check earlier Added comment to explain what the pattern is checking. --- webpack.plugin.localize.js | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index 4637c80fbeda..3c9bf932fa9b 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -51,6 +51,18 @@ const KNOWN_LOCALES = [ "he", // Hebrew "eo", // Esperanto ]; + +// A custom regular expression to do some basic checking of the plural form, +// to try to ensure the plural form expression contains only expected characters. +// - the plural form expression MUST NOT have any type of quotes and +// the only whitespace allowed is space (not tab or form feed) +// - MUST NOT allow brackets other than parentheses (()), +// as allowing braces ({}) might allow ending the function early +// - MUST allow space, number variable (n), numbers, groups (()), +// comparisons (<>!=), ternary expressions (?:), and/or (&|), +// remainder (%) +const pluralFormPattern = new RegExp("^ *nplurals *= *[0-9]+ *; *plural *=[ n0-9()<>!=?:&|%]+;?$"); + const allLocaleData = KNOWN_LOCALES .filter(langCode => langCode !== "en") .map((langCode) => resolve(localeDir, langCode, "LC_MESSAGES/messages.po")) @@ -66,12 +78,19 @@ const allLocaleData = KNOWN_LOCALES .filter(line => !line.startsWith("#~|")) .join("\n"); const parsed = gettextParser.po.parse(lines); + const language = parsed.headers["Language"]; + const pluralForms = parsed.headers["Plural-Forms"]; const result = { "": { - "language": parsed.headers["Language"], - "plural-forms": parsed.headers["Plural-Forms"], + "language": language, + "plural-forms": pluralForms, }, }; + + if (!pluralFormPattern.test(pluralForms)) { + throw new Error(`Invalid plural forms for '${language}': "${pluralForms}"`); + } + const translations = parsed.translations[""]; for (const key in translations) { if (key === "") { @@ -119,12 +138,6 @@ class WebpackLocalisationPlugin { statement.declarations[0].id.name === "messagesAccessPluralFormFunction") { const initData = statement.declarations[0].init; const pluralForms = self.localeData[""]["plural-forms"]; - const denied = new RegExp( - "[~`^$_\\[\\]{}\\\\'\"\\.#@\\f\\n\\r\\t\\v\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+"); - const required = new RegExp("^ *nplurals *= *[0-9]+ *; *plural *= *.+$"); - if (denied.test(pluralForms) || !required.test(pluralForms)) { - throw new Error(`Invalid plural forms for ${self.localeData[""].language}: ${pluralForms}`); - } const newValue = `function (n) { let nplurals, plural; ${pluralForms} From fd0dd802d7523f5d1054925138d55d55afca035e Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 21 Aug 2024 22:13:00 +1000 Subject: [PATCH 46/52] address feedback: make sure msgid and msgstr are really strings This is important as these values will be embedded into the messages-access.js file. --- webpack.plugin.localize.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index 3c9bf932fa9b..208a756eea16 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -101,7 +101,8 @@ const allLocaleData = KNOWN_LOCALES if (refs.every(refLine => !refLine.includes(".js:"))) { continue; } - result[value.msgid] = value.msgstr; + // The 'msgid' and 'msgstr' must be strings. + result[value.msgid.toString()] = value.msgstr.toString(); } return result; } catch (e) { From 6fa0444d30194c7fd474e969bb654a153bf4837a Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 22 Aug 2024 19:13:10 +1000 Subject: [PATCH 47/52] Revert change to `make statuc_pipelines` Co-authored-by: Dustin Ingram --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c8f61d0a394d..2ca0904aa408 100644 --- a/Makefile +++ b/Makefile @@ -81,7 +81,7 @@ tests: .state/docker-build-base static_tests: .state/docker-build-static docker compose run --rm static bin/static_tests $(T) $(TESTARGS) -static_pipeline: translations .state/docker-build-static +static_pipeline: .state/docker-build-static docker compose run --rm static bin/static_pipeline $(T) $(TESTARGS) reformat: .state/docker-build-base From b6edde8a7ed2def98753fbd7e9b692c9bbfde8c2 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 22 Aug 2024 19:40:24 +1000 Subject: [PATCH 48/52] encode known problematic characters Co-authored-by: Dustin Ingram --- webpack.plugin.localize.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index 208a756eea16..f1df2f10c850 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -101,8 +101,12 @@ const allLocaleData = KNOWN_LOCALES if (refs.every(refLine => !refLine.includes(".js:"))) { continue; } - // The 'msgid' and 'msgstr' must be strings. - result[value.msgid.toString()] = value.msgstr.toString(); + result[value.msgid] = value.msgstr + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } return result; } catch (e) { From ef820c2e46621c2b880546384f1a4ba95eb29414 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Sat, 31 Aug 2024 11:35:22 +1000 Subject: [PATCH 49/52] fix lint space issue --- webpack.plugin.localize.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index f1df2f10c850..a775486a9402 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -102,11 +102,11 @@ const allLocaleData = KNOWN_LOCALES continue; } result[value.msgid] = value.msgstr - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } return result; } catch (e) { From 39033cf07f08a605f8516d364bcf5e497f3621d8 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Wed, 15 Jan 2025 17:26:32 -0500 Subject: [PATCH 50/52] lint: address changes needed from `stylstic` Signed-off-by: Mike Fiedler --- tests/frontend/messages_access_test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/frontend/messages_access_test.js b/tests/frontend/messages_access_test.js index 047754f09549..c34c23a9fdc8 100644 --- a/tests/frontend/messages_access_test.js +++ b/tests/frontend/messages_access_test.js @@ -33,7 +33,7 @@ describe("messages access util", () => { expected: "My default message: more message here", }, ])("translates $singular $extras to '$expected'", async ( - {singular, extras, expected} + {singular, extras, expected}, ) => { const result = gettext(singular, ...extras); expect(result).toEqual(expected); @@ -75,7 +75,7 @@ describe("messages access util", () => { expected: "My default plural messages more message here something else.", }, ])("translates $singular $plural $num $extras to '$expected'", async ( - {singular, plural, num, extras, expected} + {singular, plural, num, extras, expected}, ) => { const result = ngettext(singular, plural, num, ...extras); expect(result).toEqual(expected); @@ -152,7 +152,7 @@ describe("messages access util", () => { expected: "My translated message 2 waves.", }, ])("translates $singular $plural $num $extras to '$expected'", async ( - {singular, plural, num, extras, expected} + {singular, plural, num, extras, expected}, ) => { const result = ngettextCustom(singular, plural, num, extras, data, pluralForms); expect(result).toEqual(expected); @@ -229,7 +229,7 @@ describe("messages access util", () => { expected: "My translated message 1 waves.", }, ])("translates $singular $plural $num $extras to '$expected'", async ( - {singular, plural, num, extras, expected} + {singular, plural, num, extras, expected}, ) => { const result = ngettextCustom(singular, plural, num, extras, data, pluralForms); expect(result).toEqual(expected); From e39d99dd83807ff9fe0a9f124085301668dab329 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Wed, 15 Jan 2025 17:28:17 -0500 Subject: [PATCH 51/52] chore: ignore (space delimited) and ignore migrations Signed-off-by: Mike Fiedler --- warehouse/locale/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/locale/Makefile b/warehouse/locale/Makefile index 5ab7b708b35d..b9a6ad6fa44e 100644 --- a/warehouse/locale/Makefile +++ b/warehouse/locale/Makefile @@ -5,7 +5,7 @@ compile-pot: PYTHONPATH=$(PWD) pybabel extract \ -F babel.cfg \ --omit-header \ - --ignore-dirs='.*' --ignore-dirs='_*' --ignore-dirs='*dist*' \ + --ignore-dirs='.* _* *dist*' 'warehouse/migrations/' \ --output="warehouse/locale/messages.pot" \ warehouse From 58005230725a60a29f997cb1f3a1f0bbe668cca9 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Wed, 15 Jan 2025 17:28:54 -0500 Subject: [PATCH 52/52] chore: update list for recent language, add note Signed-off-by: Mike Fiedler --- webpack.plugin.localize.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js index a775486a9402..a8fc647cca52 100644 --- a/webpack.plugin.localize.js +++ b/webpack.plugin.localize.js @@ -36,6 +36,7 @@ const gettextParser = require("gettext-parser"); // generate and then load the locale translation data const baseDir = __dirname; const localeDir = path.resolve(baseDir, "warehouse/locale"); +// This list should match `warehouse.i18n.KNOWN_LOCALES` const KNOWN_LOCALES = [ "en", // English "es", // Spanish @@ -50,6 +51,7 @@ const KNOWN_LOCALES = [ "ru", // Russian "he", // Hebrew "eo", // Esperanto + "ko", // Korean ]; // A custom regular expression to do some basic checking of the plural form,