diff --git a/Dockerfile b/Dockerfile
index 7d5655720fee..7fd2c3b90764 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -28,7 +28,9 @@ 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/
+COPY webpack.plugin.localize.js /opt/warehouse/src/
RUN NODE_ENV=production npm run build
diff --git a/babel.cfg b/babel.cfg
index 862cc0457b89..cd1ac95c0a50 100644
--- a/babel.cfg
+++ b/babel.cfg
@@ -3,3 +3,6 @@
encoding = utf-8
extensions=warehouse.utils.html:ClientSideIncludeExtension,warehouse.i18n.extensions.TrimmedTranslatableTagsExtension
silent=False
+[javascript: **.js]
+encoding=utf-8
+silent=False
diff --git a/docker-compose.yml b/docker-compose.yml
index 3e196e096b43..3607ef5df330 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -221,6 +221,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
- ./babel.config.js:/opt/warehouse/src/babel.config.js:z
- ./.stylelintrc.json:/opt/warehouse/src/.stylelintrc.json:z
- ./tests/frontend:/opt/warehouse/src/tests/frontend:z
diff --git a/docs/dev/development/frontend.rst b/docs/dev/development/frontend.rst
index 06dd64c08e24..db0a17e675da 100644
--- a/docs/dev/development/frontend.rst
+++ b/docs/dev/development/frontend.rst
@@ -151,3 +151,25 @@ 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/translations.rst b/docs/dev/translations.rst
index 10881a449e02..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
@@ -62,11 +64,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, 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
@@ -86,6 +100,15 @@ 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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -105,6 +128,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/package-lock.json b/package-lock.json
index 0bab21816af7..be651a32ffcc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,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",
@@ -4023,6 +4024,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",
@@ -5353,6 +5366,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",
@@ -6598,6 +6620,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",
@@ -7205,6 +7236,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",
@@ -7660,6 +7700,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",
@@ -12747,6 +12868,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 722fa24c072d..76be762bc807 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": "NODE_OPTIONS='$NODE_OPTIONS --experimental-vm-modules' jest --coverage"
@@ -45,6 +45,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/tests/frontend/messages_access_test.js b/tests/frontend/messages_access_test.js
new file mode 100644
index 000000000000..c34c23a9fdc8
--- /dev/null
+++ b/tests/frontend/messages_access_test.js
@@ -0,0 +1,238 @@
+/* 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.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(expected);
+ });
+ });
+
+ describe("ngettext with defaults", () => {
+ 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(expected);
+ });
+ });
+
+ 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": "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."],
+ };
+ // 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);
+ 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 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(expected);
+ });
+ });
+
+ 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(expected);
+ });
+ });
+});
diff --git a/tests/frontend/password_strength_gauge_controller_test.js b/tests/frontend/password_strength_gauge_controller_test.js
index ebd92920c733..9f7a7a4b3244 100644
--- a/tests/frontend/password_strength_gauge_controller_test.js
+++ b/tests/frontend/password_strength_gauge_controller_test.js
@@ -37,10 +37,13 @@ describe("Password strength gauge controller", () => {
application.register("password-strength-gauge", PasswordStrengthGaugeController);
});
-
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 () => {
+
+ 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 =>
@@ -74,8 +77,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,6 +86,7 @@ describe("Password strength gauge controller", () => {
},
};
});
+
const passwordTarget = getByPlaceholderText(document.body, "Your password");
fireEvent.input(passwordTarget, { target: { value: "the strongest password ever" } });
diff --git a/tests/frontend/timeago_test.js b/tests/frontend/timeago_test.js
new file mode 100644
index 000000000000..abb665201417
--- /dev/null
+++ b/tests/frontend/timeago_test.js
@@ -0,0 +1,98 @@
+/* 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";
+ });
+
+ it("shows 'just now' for a very recent time'", async () => {
+ document.body.innerHTML = `
+
+ `;
+
+ timeAgo();
+
+ await delay(25);
+
+ const element = document.getElementById("element");
+ expect(element.innerText).toEqual("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 = `
+
+ `;
+
+ timeAgo();
+
+ await delay(25);
+
+ const element = document.getElementById("element");
+ expect(element.innerText).toEqual("About 5 hours ago");
+ });
+
+ it("shows 'About 36 minutes ago' for such a time'", async () => {
+ const date = new Date();
+ date.setMinutes(date.getMinutes() - 36);
+
+ document.body.innerHTML = `
+
+ `;
+
+ timeAgo();
+
+ await delay(25);
+
+ const element = document.getElementById("element");
+ expect(element.innerText).toEqual("About 36 minutes ago");
+ });
+
+ it("shows provided text for Yesterday'", async () => {
+ const date = new Date();
+ date.setHours(date.getHours() - 24);
+
+ document.body.innerHTML = `
+
+ `;
+
+ timeAgo();
+
+ await delay(25);
+
+ const element = document.getElementById("element");
+ expect(element.innerText).toEqual("Yesterday");
+ });
+
+ it("makes no call when not isBeforeCutoff", async () => {
+ document.body.innerHTML = `
+
+ `;
+
+ timeAgo();
+
+ await delay(25);
+
+ const element = document.getElementById("element");
+ expect(element.textContent).toEqual("existing text");
+ });
+});
diff --git a/warehouse/forms.py b/warehouse/forms.py
index da130ce69b8f..c3166dcd6d93 100644
--- a/warehouse/forms.py
+++ b/warehouse/forms.py
@@ -76,6 +76,8 @@ def __call__(self, form, field):
msg = (
results["feedback"]["warning"]
if results["feedback"]["warning"]
+ # 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"]:
diff --git a/warehouse/i18n/__init__.py b/warehouse/i18n/__init__.py
index d87100c344de..42df0a143dad 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/Makefile b/warehouse/locale/Makefile
index bf8aa397a9fd..b9a6ad6fa44e 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*' 'warehouse/migrations/' \
--output="warehouse/locale/messages.pot" \
warehouse
diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot
index 526617f765c2..f8f88a3c4257 100644
--- a/warehouse/locale/messages.pot
+++ b/warehouse/locale/messages.pot
@@ -788,6 +788,53 @@ 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 ""
+
+#: warehouse/static/js/warehouse/controllers/password_match_controller.js:32
+msgid "Passwords match"
+msgstr ""
+
+#: warehouse/static/js/warehouse/controllers/password_match_controller.js:36
+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:42
+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 ""
+
#: warehouse/subscriptions/models.py:35
#: warehouse/templates/manage/project/history.html:230
msgid "Active"
@@ -1003,10 +1050,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"
diff --git a/warehouse/static/js/warehouse/controllers/clipboard_controller.js b/warehouse/static/js/warehouse/controllers/clipboard_controller.js
index 39fd69dc521e..22e47ae41d3a 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/messages-access";
// Copy handler for copy tooltips, e.g.
// - the pip command on package detail page
@@ -27,8 +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"
- this.tooltipTarget.dataset.clipboardTooltipValue = "Copied";
+ // 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 677c73fbb389..56de86c90608 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/messages-access";
export default class extends Controller {
static targets = ["password", "message"];
@@ -44,8 +45,8 @@ 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
+ 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 076f304d09fe..7f540f4ebd54 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/messages-access";
export default class extends Controller {
static targets = ["passwordMatch", "matchMessage", "submit"];
@@ -28,11 +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)) {
- this.matchMessageTarget.textContent = "Passwords match";
+ this.matchMessageTarget.textContent = gettext("Passwords match");
this.matchMessageTarget.classList.add("form-error--valid");
this.submitTarget.removeAttribute("disabled");
} else {
- this.matchMessageTarget.textContent = "Passwords do not match";
+ 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 9ef690d149ae..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,6 +15,7 @@
/* global zxcvbn */
import { Controller } from "@hotwired/stimulus";
+import { gettext } from "../utils/messages-access";
export default class extends Controller {
static targets = ["password", "strengthGauge"];
@@ -23,7 +24,7 @@ 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");
+ 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`
@@ -31,7 +32,15 @@ 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");
+
+ const feedbackSuggestions = zxcvbnResult.feedback.suggestions.join(" ");
+ if (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 {
+ 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
new file mode 100644
index 000000000000..978b156bda4c
--- /dev/null
+++ b/warehouse/static/js/warehouse/utils/messages-access.js
@@ -0,0 +1,134 @@
+/* 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.
+ */
+
+// 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 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))};
+};
+
+/**
+ * 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 'ngettext' for plural extraction.
+ *
+ * Any placeholders must be specified as %1, %2, etc.
+ *
+ * @example
+ * 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, numMinutes);
+
+ * @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.
+ * @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) {
+ return ngettextCustom(singular, plural, num, extras, messagesAccessLocaleData, messagesAccessPluralFormFunction);
+}
+
+/**
+ * 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 extraction.
+ *
+ * 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) {
+ 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 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)) {
+ value = value[pluralFormsData.index];
+ } else if (pluralFormsData.index > 0) {
+ value = plural;
+ }
+ 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 "";
+ }
+ if (Object.hasOwn(data, value)) {
+ return data[value];
+ } else {
+ return 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 "";
+ }
+ if (!extras || extras.length < 1 || !value.includes("%")) {
+ 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 fafb94506410..d34e411d06a2 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 { gettext, ngettext } from "../utils/messages-access";
+
const enumerateTime = (timestampString) => {
const now = new Date(),
timestamp = new Date(timestampString),
@@ -28,17 +30,15 @@ const convertToReadableText = (time) => {
let { numDays, numMinutes, numHours } = time;
if (numDays >= 1) {
- return numDays == 1 ? "Yesterday" : `About ${numDays} days ago`;
+ return ngettext("Yesterday", "About %1 days ago", numDays, numDays);
}
if (numHours > 0) {
- numHours = numHours != 1 ? `${numHours} hours` : "an hour";
- return `About ${numHours} ago`;
+ return ngettext("About an hour ago", "About %1 hours ago", numHours, numHours);
} else if (numMinutes > 0) {
- numMinutes = numMinutes > 1 ? `${numMinutes} minutes` : "a minute";
- return `About ${numMinutes} ago`;
+ return ngettext("About a minute ago", "About %1 minutes ago", numMinutes, numMinutes);
} else {
- return "Just now";
+ return gettext("Just now", "another");
}
};
@@ -47,6 +47,8 @@ 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) {
+ timeElement.innerText = convertToReadableText(time);
+ }
}
};
diff --git a/warehouse/templates/base.html b/warehouse/templates/base.html
index bdd7f2054c5e..ed1ded0e4ee5 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' + ('' 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 6ff06e3da2ad..b833270d55d1 100755
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -11,22 +11,24 @@
* limitations under the License.
*/
+/* global module, __dirname */
+
// This is the main configuration file for webpack.
// See: https://webpack.js.org/configuration/
const path = require("path");
const zlib = require("zlib");
-const glob = require("glob");
const rtlcss = require("rtlcss");
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 {WebpackManifestPlugin} = require("webpack-manifest-plugin");
+const {WebpackLocalisationPlugin, allLocaleData} = require("./webpack.plugin.localize.js");
/* Shared Plugins */
@@ -34,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,
}),
@@ -61,55 +63,55 @@ 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
- }
- }
- // 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
+
+// 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.
+ // 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
}
- return file;
- },
- // Refs: https://github.com/shellscape/webpack-manifest-plugin/issues/229#issuecomment-737617994
- publicPath: "",
- }),
-];
+ }
+ // 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;
+ };
/* 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 = [
{
name: "warehouse",
experiments: {
- // allow us to manage RTL CSS as a separate file
+ // 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.
+ // 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
+ // Copy vendored zxcvbn code
from: path.resolve(__dirname, "warehouse/static/js/vendor/zxcvbn.js"),
to: "js/vendor/[name].[contenthash][ext]",
},
@@ -117,12 +119,17 @@ module.exports = [
}),
...sharedCompressionPlugins,
...sharedCSSPlugins,
- ...sharedWebpackManifestPlugins,
+ new WebpackManifestPlugin({
+ removeKeyHash: /([a-f0-9]{8}\.?)/gi,
+ publicPath: sharedWebpackManifestPublicPath,
+ 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: {
@@ -150,7 +157,7 @@ module.exports = [
// See: https://webpack.js.org/configuration/devtool
devtool: "source-map",
output: {
- // remove old files
+ // remove old files
clean: true,
// Matches current behavior. Defaults to 20. 16 in the future.
hashDigestLength: 8,
@@ -162,12 +169,12 @@ module.exports = [
module: {
rules: [
{
- // Handle SASS/SCSS/CSS files
+ // 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
+ // For the `rtl` file, needs postcss processing
layer: "rtl",
issuerLayer: "rtl",
use: [
@@ -185,9 +192,9 @@ module.exports = [
],
},
{
- // All other CSS files
+ // All other CSS files
use: [
- // Extracts CSS into separate files
+ // Extracts CSS into separate files
MiniCssExtractPlugin.loader,
// Translates CSS into CommonJS
"css-loader",
@@ -198,7 +205,7 @@ module.exports = [
],
},
{
- // Handle image files
+ // Handle image files
test: /\.(png|svg|jpg|jpeg|gif)$/i,
// disables data URL inline encoding images into CSS,
// since it violates our CSP settings.
@@ -208,7 +215,7 @@ module.exports = [
},
},
{
- // Handle font files
+ // Handle font files
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: "asset/resource",
generator: {
@@ -219,7 +226,7 @@ module.exports = [
},
optimization: {
minimizer: [
- // default minimizer is Terser for JS. Extend here vs overriding.
+ // default minimizer is Terser for JS. Extend here vs overriding.
"...",
// Minimize CSS
new CssMinimizerPlugin({
@@ -227,7 +234,7 @@ module.exports = [
preset: [
"default",
{
- discardComments: { removeAll: true },
+ discardComments: {removeAll: true},
},
],
},
@@ -240,7 +247,7 @@ module.exports = [
},
generator: [
{
- // Apply generator for copied assets
+ // Apply generator for copied assets
type: "asset",
implementation: ImageMinimizerPlugin.sharpGenerate,
options: {
@@ -278,7 +285,11 @@ module.exports = [
plugins: [
...sharedCompressionPlugins,
...sharedCSSPlugins,
- ...sharedWebpackManifestPlugins,
+ new WebpackManifestPlugin({
+ removeKeyHash: /([a-f0-9]{8}\.?)/gi,
+ publicPath: sharedWebpackManifestPublicPath,
+ map: sharedWebpackManifestMap,
+ }),
// admin site dependencies use jQuery
new ProvidePlugin({
$: "jquery",
@@ -314,7 +325,7 @@ module.exports = [
],
},
{
- // Handle image files
+ // Handle image files
test: /\.(png|svg|jpg|jpeg|gif)$/i,
// disables data URL inline encoding images into CSS,
// since it violates our CSP settings.
@@ -333,4 +344,45 @@ module.exports = [
],
},
},
+ // 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: sharedWebpackManifestPublicPath,
+ 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`,
+ },
+ },
+ // 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"],
+ };
+ }),
];
diff --git a/webpack.plugin.localize.js b/webpack.plugin.localize.js
new file mode 100644
index 000000000000..a8fc647cca52
--- /dev/null
+++ b/webpack.plugin.localize.js
@@ -0,0 +1,172 @@
+/* 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.
+ * 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.
+ */
+
+// 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
+
+/* global module, __dirname */
+
+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");
+
+// 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
+ "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
+ "ko", // Korean
+];
+
+// 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"))
+ .filter((file) => fs.statSync(file).isFile())
+ .map((file) => ({path: path.relative(baseDir, file), data: fs.readFileSync(file, "utf8")}))
+ .map((data) => {
+ try {
+ 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 language = parsed.headers["Language"];
+ const pluralForms = parsed.headers["Plural-Forms"];
+ const result = {
+ "": {
+ "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 === "") {
+ 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
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+ return result;
+ } catch (e) {
+ throw new Error(`Could not parse file ${data.path}: ${e.message}\n${e}`);
+ }
+ });
+
+
+const pluginName = "WebpackLocalisationPlugin";
+
+class WebpackLocalisationPlugin {
+ constructor(localeData) {
+ this.localeData = localeData || {};
+ }
+
+ apply(compiler) {
+ const self = this;
+
+ // create a handler for each factory.hooks.parser
+ const handler = function (parser) {
+
+ 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;
+
+ } 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 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;
+
+ }
+ });
+ };
+
+ // 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.allLocaleData = allLocaleData;