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;