From 986c3750523697e0275179f530ecbd3b118203d9 Mon Sep 17 00:00:00 2001 From: DaneEveritt Date: Sat, 11 Jun 2022 14:04:09 -0400 Subject: [PATCH] Improve support for use of i18next; rely on browser caching to keep things simple --- .../Controllers/Base/LocaleController.php | 52 ++++++++++--- package.json | 7 +- resources/lang/en/activity.php | 2 + resources/scripts/i18n.ts | 37 +++++----- resources/scripts/index.tsx | 4 +- resources/scripts/routers/DashboardRouter.tsx | 43 ++++++----- routes/base.php | 2 +- webpack.config.js | 6 ++ yarn.lock | 74 ++++++++++++++----- 9 files changed, 152 insertions(+), 75 deletions(-) diff --git a/app/Http/Controllers/Base/LocaleController.php b/app/Http/Controllers/Base/LocaleController.php index b6f2da4f5..1842e94db 100644 --- a/app/Http/Controllers/Base/LocaleController.php +++ b/app/Http/Controllers/Base/LocaleController.php @@ -6,20 +6,15 @@ use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Translation\Translator; use Pterodactyl\Http\Controllers\Controller; +use Illuminate\Contracts\Translation\Loader; class LocaleController extends Controller { - /** - * @var \Illuminate\Translation\Translator - */ - private $translator; + protected Loader $loader; - /** - * LocaleController constructor. - */ public function __construct(Translator $translator) { - $this->translator = $translator; + $this->loader = $translator->getLoader(); } /** @@ -27,12 +22,45 @@ class LocaleController extends Controller * * @return \Illuminate\Http\JsonResponse */ - public function __invoke(Request $request, string $locale, string $namespace) + public function __invoke(Request $request) { - $data = $this->translator->getLoader()->load($locale, str_replace('.', '/', $namespace)); + $locales = explode(' ', $request->input('locale') ?? ''); + $namespaces = explode(' ', $request->input('namespace') ?? ''); - return new JsonResponse($data, 200, [ - 'E-Tag' => md5(json_encode($data)), + $response = []; + foreach ($locales as $locale) { + $response[$locale] = []; + foreach ($namespaces as $namespace) { + $response[$locale][$namespace] = $this->i18n( + $this->loader->load($locale, str_replace('.', '/', $namespace)) + ); + } + } + + return new JsonResponse($response, 200, [ + // Cache this in the browser for an hour, and allow the browser to use a stale + // cache for up to a day after it was created while it fetches an updated set + // of translation keys. + 'Cache-Control' => 'public, max-age=3600, stale-while-revalidate=86400', + 'ETag' => md5(json_encode($response, JSON_THROW_ON_ERROR)), ]); } + + /** + * Convert standard Laravel translation keys that look like ":foo" + * into key structures that are supported by the front-end i18n + * library, like "{{foo}}". + */ + protected function i18n(array $data): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->i18n($value); + } else { + $data[$key] = preg_replace('/:([\w-]+)(\W?|$)/m', '{{$1}}$2', $value); + } + } + + return $data; + } } diff --git a/package.json b/package.json index cae393305..0b72d1ac1 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,9 @@ "events": "^3.0.0", "formik": "^2.2.6", "framer-motion": "^6.3.10", - "i18next": "^19.0.0", - "i18next-chained-backend": "^2.0.0", - "i18next-localstorage-backend": "^3.0.0", - "i18next-xhr-backend": "^3.2.2", + "i18next": "^21.8.9", + "i18next-http-backend": "^1.4.1", + "i18next-multiload-backend-adapter": "^1.0.0", "qrcode.react": "^1.0.1", "query-string": "^6.7.0", "react": "^16.14.0", diff --git a/resources/lang/en/activity.php b/resources/lang/en/activity.php index 7e725daaf..352758c5e 100644 --- a/resources/lang/en/activity.php +++ b/resources/lang/en/activity.php @@ -8,6 +8,8 @@ */ return [ 'auth' => [ + 'fail' => 'Failed login attempt', + 'success' => 'Successfully logged in', 'password-reset' => 'Reset account password', 'reset-password' => 'Sending password reset email', 'checkpoint' => 'Prompting for second factor authentication', diff --git a/resources/scripts/i18n.ts b/resources/scripts/i18n.ts index 1f80a2e13..4f68c6175 100644 --- a/resources/scripts/i18n.ts +++ b/resources/scripts/i18n.ts @@ -1,32 +1,35 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import LocalStorageBackend from 'i18next-localstorage-backend'; -import XHR from 'i18next-xhr-backend'; -import Backend from 'i18next-chained-backend'; +import I18NextHttpBackend, { BackendOptions } from 'i18next-http-backend'; +import I18NextMultiloadBackendAdapter from 'i18next-multiload-backend-adapter'; + +// If we're using HMR use a unique hash per page reload so that we're always +// doing cache busting. Otherwise just use the builder provided hash value in +// the URL to allow cache busting to occur whenever the front-end is rebuilt. +const hash = module.hot ? Date.now().toString(16) : process.env.WEBPACK_BUILD_HASH; i18n - .use(Backend) + .use(I18NextMultiloadBackendAdapter) .use(initReactI18next) .init({ - debug: process.env.NODE_ENV !== 'production', + debug: process.env.DEBUG === 'true', lng: 'en', fallbackLng: 'en', keySeparator: '.', backend: { - backends: [ - LocalStorageBackend, - XHR, - ], - backendOptions: [ { - prefix: 'pterodactyl_lng__', - expirationTime: 7 * 24 * 60 * 60 * 1000, // 7 days, in milliseconds - store: window.localStorage, - }, { - loadPath: '/locales/{{lng}}/{{ns}}.json', - } ], + backend: I18NextHttpBackend, + backendOption: { + loadPath: `/locales/locale.json?locale={{lng}}&namespace={{ns}}&hash=${hash}`, + allowMultiLoading: true, + } as BackendOptions, + } as Record, + interpolation: { + // Per i18n-react documentation: this is not needed since React is already + // handling escapes for us. + escapeValue: false, }, }); -// i18n.loadNamespaces(['validation']); +i18n.loadNamespaces([ 'validation' ]).catch(console.error); export default i18n; diff --git a/resources/scripts/index.tsx b/resources/scripts/index.tsx index 5b7643242..23054ac57 100644 --- a/resources/scripts/index.tsx +++ b/resources/scripts/index.tsx @@ -1,9 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import App from '@/components/App'; -import './i18n'; import { setConfig } from 'react-hot-loader'; +// Enable language support. +import './i18n'; + // Prevents page reloads while making component changes which // also avoids triggering constant loading indicators all over // the place in development. diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index f2f41cb62..bb760fed3 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -10,6 +10,7 @@ import SubNavigation from '@/components/elements/SubNavigation'; import AccountSSHContainer from '@/components/dashboard/ssh/AccountSSHContainer'; import { useLocation } from 'react-router'; import ActivityLogContainer from '@/components/dashboard/activity/ActivityLogContainer'; +import Spinner from '@/components/elements/Spinner'; export default () => { const location = useLocation(); @@ -28,26 +29,28 @@ export default () => { } - - - - - - - - - - - - - - - - - - - - + }> + + + + + + + + + + + + + + + + + + + + + ); diff --git a/routes/base.php b/routes/base.php index 8ca1fbc3a..1d3ad66a6 100644 --- a/routes/base.php +++ b/routes/base.php @@ -8,7 +8,7 @@ Route::get('/account', [Base\IndexController::class, 'index']) ->withoutMiddleware(RequireTwoFactorAuthentication::class) ->name('account'); -Route::get('/locales/{locale}/{namespace}.json', Base\LocaleController::class) +Route::get('/locales/locale.json', Base\LocaleController::class) ->withoutMiddleware(['auth', RequireTwoFactorAuthentication::class]) ->where('namespace', '.*'); diff --git a/webpack.config.js b/webpack.config.js index f09e5ed31..aa240fbd6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,4 +1,5 @@ const path = require('path'); +const webpack = require('webpack'); const AssetsManifestPlugin = require('webpack-assets-manifest'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); @@ -94,6 +95,11 @@ module.exports = { moment: 'moment', }, plugins: [ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + DEBUG: process.env.NODE_ENV !== 'production', + WEBPACK_BUILD_HASH: Date.now().toString(16), + }), new AssetsManifestPlugin({ writeToDisk: true, publicPath: true, integrity: true, integrityHashes: ['sha384'] }), new ForkTsCheckerWebpackPlugin({ typescript: { diff --git a/yarn.lock b/yarn.lock index b3d9782cf..2e4264f5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1010,7 +1010,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-typescript" "^7.12.1" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.0", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3": version "7.7.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.5.tgz#4b087f183f5d83647744d4157f66199081d17a00" dependencies: @@ -1023,6 +1023,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.17.2": + version "7.18.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.3.tgz#c7b654b57f6f63cf7f8b418ac9ca04408c4579f4" + integrity sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/runtime@^7.7.2", "@babel/runtime@^7.9.6": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.4.tgz#a6724f1a6b8d2f6ea5236dbfe58c7d7ea9c5eb99" @@ -3011,6 +3018,13 @@ cross-env@^7.0.2: dependencies: cross-spawn "^7.0.1" +cross-fetch@3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -4701,29 +4715,24 @@ https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" -i18next-chained-backend@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/i18next-chained-backend/-/i18next-chained-backend-2.0.0.tgz#faf2e8b5f081a01e74fbec1fe580c184bc64e25b" +i18next-http-backend@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-1.4.1.tgz#d8d308e7d8c5b89988446d0b83f469361e051bc0" + integrity sha512-s4Q9hK2jS29iyhniMP82z+yYY8riGTrWbnyvsSzi5TaF7Le4E7b5deTmtuaRuab9fdDcYXtcwdBgawZG+JCEjA== dependencies: - "@babel/runtime" "^7.4.5" + cross-fetch "3.1.5" -i18next-localstorage-backend@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/i18next-localstorage-backend/-/i18next-localstorage-backend-3.0.0.tgz#19b4e836e9a79e564631b88b8ba1c738375e636f" - dependencies: - "@babel/runtime" "^7.4.5" +i18next-multiload-backend-adapter@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/i18next-multiload-backend-adapter/-/i18next-multiload-backend-adapter-1.0.0.tgz#3cc3ea102814273bb9059a317d04a3b6e4316121" + integrity sha512-rZd/Qmr7KkGktVgJa78GPLXEnd51OyB2I9qmbI/mXKPm3MWbXwplIApqmZgxkPC9ce+b8Jnk227qX62W9SaLPQ== -i18next-xhr-backend@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/i18next-xhr-backend/-/i18next-xhr-backend-3.2.2.tgz#769124441461b085291f539d91864e3691199178" +i18next@^21.8.9: + version "21.8.9" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.8.9.tgz#c79edd5bba61e0a0d5b43a93d52e2d13a526de82" + integrity sha512-PY9a/8ADVmnju1tETeglbbVQi+nM5pcJQWm9kvKMTE3GPgHHtpDsHy5HQ/hccz2/xtW7j3vuso23JdQSH0EttA== dependencies: - "@babel/runtime" "^7.5.5" - -i18next@^19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/i18next/-/i18next-19.0.0.tgz#5418207d7286128e6cfe558e659fa8c60d89794b" - dependencies: - "@babel/runtime" "^7.3.1" + "@babel/runtime" "^7.17.2" iconv-lite@0.4.24, iconv-lite@^0.4.4: version "0.4.24" @@ -5833,6 +5842,13 @@ node-emoji@^1.11.0: dependencies: lodash "^4.17.21" +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + node-forge@0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" @@ -8394,6 +8410,11 @@ toposort@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + tryer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" @@ -8704,6 +8725,11 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= + webpack-assets-manifest@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/webpack-assets-manifest/-/webpack-assets-manifest-3.1.1.tgz#39bbc3bf2ee57fcd8ba07cda51c9ba4a3c6ae1de" @@ -8869,6 +8895,14 @@ whatwg-mimetype@^2.3.0: resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0= + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"