UX: Reduce JS bundle size by loading translation files on demand #4778

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-03-24 12:43:08 +01:00
parent a125219751
commit 0ddc179737
52 changed files with 126 additions and 44 deletions

View File

@@ -0,0 +1,21 @@
const glob = require("glob");
const path = require("path");
const poPath = path.resolve(__dirname, "src/locales");
// Generates a list of existing locales based on the files in src/locales.
const languageCodes = glob.sync(poPath + "/*.po").map((filePath) => {
const fileName = path.basename(filePath);
return fileName.replace(".po", "");
});
// Generates one JSON file per locale from the gettext *.po files located in src/locales.
module.exports = {
output: {
path: path.resolve(__dirname, "src/locales"),
potPath: "src/locales/translations.pot",
jsonPath: "json",
locales: languageCodes,
splitJson: true,
flat: true,
},
};

View File

@@ -3636,9 +3636,9 @@
} }
}, },
"node_modules/@pkgr/core": { "node_modules/@pkgr/core": {
"version": "0.1.2", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz",
"integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==", "integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0" "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
@@ -3710,9 +3710,9 @@
} }
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
@@ -3800,9 +3800,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.11", "version": "22.13.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.11.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz",
"integrity": "sha512-iEUCUJoU0i3VnrCmgoWCXttklWcvoCIx4jzcP22fioIVSdTmjgoEvmAO/QPw6TcS9k5FrNgn4w7q5lGOd1CT5g==", "integrity": "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~6.20.0"
@@ -7775,13 +7775,13 @@
} }
}, },
"node_modules/eslint-plugin-prettier": { "node_modules/eslint-plugin-prettier": {
"version": "5.2.3", "version": "5.2.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.4.tgz",
"integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "integrity": "sha512-SFtuYmnhwYCtuCDTKPoK+CEzCnEgKTU2qTLwoCxvrC0MFBTIXo1i6hDYOI4cwHaE5GZtlWmTN3YfucYi7KJwPw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prettier-linter-helpers": "^1.0.0", "prettier-linter-helpers": "^1.0.0",
"synckit": "^0.9.1" "synckit": "^0.10.2"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
@@ -16128,13 +16128,13 @@
} }
}, },
"node_modules/synckit": { "node_modules/synckit": {
"version": "0.9.2", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz",
"integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", "integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@pkgr/core": "^0.1.0", "@pkgr/core": "^0.2.0",
"tslib": "^2.6.2" "tslib": "^2.8.1"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"

View File

@@ -19,7 +19,7 @@
"testcafe": "testcafe", "testcafe": "testcafe",
"acceptance-local": "testcafe chromium --selector-timeout 5000 -S -s tests/acceptance/screenshots tests/acceptance", "acceptance-local": "testcafe chromium --selector-timeout 5000 -S -s tests/acceptance/screenshots tests/acceptance",
"gettext-extract": "gettext-extract --output src/locales/translations.pot $(find ${SRC:-src} -type f \\( -iname \\*.vue -o -iname \\*.js \\) -not -path src/common/gettext.js)", "gettext-extract": "gettext-extract --output src/locales/translations.pot $(find ${SRC:-src} -type f \\( -iname \\*.vue -o -iname \\*.js \\) -not -path src/common/gettext.js)",
"gettext-compile": "gettext-compile --output src/locales/translations.json src/locales/*.po", "gettext-compile": "vue-gettext-compile",
"dep-list": "npx npm-check-updates" "dep-list": "npx npm-check-updates"
}, },
"dependencies": { "dependencies": {

View File

@@ -26,7 +26,6 @@ Additional information can be found in our Developer Guide:
import $api from "common/api"; import $api from "common/api";
import $event from "common/event"; import $event from "common/event";
import * as themes from "options/themes"; import * as themes from "options/themes";
import translations from "locales/translations.json";
import { Languages } from "options/options"; import { Languages } from "options/options";
import { Photo } from "model/photo"; import { Photo } from "model/photo";
import { onInit, onSetTheme } from "common/hooks"; import { onInit, onSetTheme } from "common/hooks";
@@ -48,7 +47,7 @@ export default class Config {
this.updating = false; this.updating = false;
this.$vuetify = null; this.$vuetify = null;
this.translations = translations; this.translations = {};
if (!values || !values.siteTitle) { if (!values || !values.siteTitle) {
// Omit warning in unit tests. // Omit warning in unit tests.
@@ -166,7 +165,7 @@ export default class Config {
return this.updating; return this.updating;
} }
setValues(values) { async setValues(values) {
if (!values || typeof values !== "object") { if (!values || typeof values !== "object") {
return; return;
} }
@@ -185,7 +184,7 @@ export default class Config {
if (values.settings) { if (values.settings) {
this.setBatchSize(values.settings); this.setBatchSize(values.settings);
this.setLanguage(values.settings.ui.language, true); await this.setLanguage(values.settings.ui.language, true);
this.setTheme(values.settings.ui.theme); this.setTheme(values.settings.ui.theme);
} }
@@ -417,9 +416,29 @@ export default class Config {
return !this.allowAny(resource, perm); return !this.allowAny(resource, perm);
} }
// loadTranslation asynchronously loads the specified locale file.
async loadTranslation(locale) {
if (!locale || (this.translations && this.translations[locale])) {
return;
}
try {
// Dynamically import the translation JSON file.
await import(
/* webpackChunkName: "[request]" */
/* webpackMode: "lazy" */
`../locales/json/${locale}.json`
).then((module) => {
Object.assign(this.translations, module.default);
});
} catch (error) {
console.error(`failed to load translations for locale ${locale}:`, error);
}
}
// setLanguage sets the ISO/IEC 15897 locale, // setLanguage sets the ISO/IEC 15897 locale,
// e.g. "en" or "zh_TW" (minimum 2 letters). // e.g. "en" or "zh_TW" (minimum 2 letters).
setLanguage(locale, apply) { async setLanguage(locale, apply) {
// Skip setting language if no locale is specified. // Skip setting language if no locale is specified.
if (!locale) { if (!locale) {
return this; return this;
@@ -427,6 +446,8 @@ export default class Config {
// Apply locale to browser window? // Apply locale to browser window?
if (apply) { if (apply) {
await this.loadTranslation(locale);
// Update the Accept-Language header for XHR requests. // Update the Accept-Language header for XHR requests.
if ($api) { if ($api) {
$api.defaults.headers.common["Accept-Language"] = locale; $api.defaults.headers.common["Accept-Language"] = locale;

View File

@@ -26,13 +26,13 @@ In addition, the new language needs to be added to the `Languages` function in `
A binary `*.mo` (machine object) file will be automatically saved along with every `*.po` file. A binary `*.mo` (machine object) file will be automatically saved along with every `*.po` file.
You won't be able to open those in a text editor, but please include them in git commits or when sending You won't be able to open those in a text editor, but please include them in git commits or when sending
translations via email. The compiled `translations.json` file is not required for pull requests translations via email. The compiled `*.json` files are not required for pull requests
and often causes merge conflicts. and often causes merge conflicts.
If you have a working development environment in place: If you have a working development environment in place:
Running `npm run gettext-compile` in the `frontend` directory compiles existing translations into Running `npm run gettext-compile` in the `frontend` directory compiles existing translations into `*.json` files that
a single `translations.json` file. can be imported by the frontend.
Now start a frontend build using `npm run build` or keep Now start a frontend build using `npm run build` or keep

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"en":{"Next":"Next"}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -290,22 +290,22 @@ describe("common/config", () => {
assert.equal(cfg.values.count.all, 139); assert.equal(cfg.values.count.all, 139);
}); });
it("should return user interface direction string", () => { it("should return user interface direction string", async () => {
const cfg = new Config(new StorageShim(), Object.assign({}, window.__CONFIG__)); const cfg = new Config(new StorageShim(), Object.assign({}, window.__CONFIG__));
cfg.setLanguage("en", true); await cfg.setLanguage("en", true);
assert.equal(document.dir, "ltr", "document.dir should be ltr"); assert.equal(document.dir, "ltr", "document.dir should be ltr");
assert.equal(cfg.dir(), "ltr"); assert.equal(cfg.dir(), "ltr");
assert.equal(cfg.dir(true), "rtl"); assert.equal(cfg.dir(true), "rtl");
assert.equal(cfg.dir(false), "ltr"); assert.equal(cfg.dir(false), "ltr");
cfg.setLanguage("he", false); await cfg.setLanguage("he", false);
assert.equal(document.dir, "ltr", "document.dir should still be ltr"); assert.equal(document.dir, "ltr", "document.dir should still be ltr");
cfg.setLanguage("he", true); await cfg.setLanguage("he", true);
assert.equal(cfg.dir(), "rtl"); assert.equal(cfg.dir(), "rtl");
assert.equal(document.dir, "rtl", "document.dir should now be rtl"); assert.equal(document.dir, "rtl", "document.dir should now be rtl");
assert.equal(cfg.dir(), "rtl"); assert.equal(cfg.dir(), "rtl");
assert.equal(cfg.dir(true), "rtl"); assert.equal(cfg.dir(true), "rtl");
assert.equal(cfg.dir(false), "ltr"); assert.equal(cfg.dir(false), "ltr");
cfg.setLanguage("en", true); await cfg.setLanguage("en", true);
assert.equal(document.dir, "ltr", "document.dir should be ltr again"); assert.equal(document.dir, "ltr", "document.dir should be ltr again");
assert.equal(cfg.dir(), "ltr"); assert.equal(cfg.dir(), "ltr");
}); });

View File

@@ -46,7 +46,6 @@ const PATHS = {
share: path.join(__dirname, "src/share.js"), share: path.join(__dirname, "src/share.js"),
splash: path.join(__dirname, "src/splash.js"), splash: path.join(__dirname, "src/splash.js"),
build: path.join(__dirname, "../assets/static/build"), build: path.join(__dirname, "../assets/static/build"),
public: "./",
}; };
if (isCustom) { if (isCustom) {
@@ -72,8 +71,10 @@ const config = {
}, },
output: { output: {
path: PATHS.build, path: PATHS.build,
publicPath: PATHS.public, publicPath: "auto",
filename: "[name].[contenthash].js", filename: "[name].[contenthash].js",
chunkFilename: "chunk/[name].[contenthash].js",
asyncChunks: true,
clean: true, clean: true,
}, },
resolve: { resolve: {
@@ -147,6 +148,11 @@ const config = {
}, },
], ],
}, },
{
test: /\.json$/,
include: PATHS.src,
type: "json",
},
{ {
test: /\.css$/, test: /\.css$/,
include: isCustom ? [PATHS.custom, PATHS.css] : [PATHS.css], include: isCustom ? [PATHS.custom, PATHS.css] : [PATHS.css],
@@ -154,9 +160,6 @@ const config = {
use: [ use: [
{ {
loader: MiniCssExtractPlugin.loader, loader: MiniCssExtractPlugin.loader,
options: {
publicPath: PATHS.public,
},
}, },
{ {
loader: "css-loader", loader: "css-loader",
@@ -183,9 +186,6 @@ const config = {
use: [ use: [
{ {
loader: MiniCssExtractPlugin.loader, loader: MiniCssExtractPlugin.loader,
options: {
publicPath: PATHS.public,
},
}, },
{ {
loader: "css-loader", loader: "css-loader",
@@ -211,9 +211,6 @@ const config = {
use: [ use: [
{ {
loader: MiniCssExtractPlugin.loader, loader: MiniCssExtractPlugin.loader,
options: {
publicPath: PATHS.public,
},
}, },
{ {
loader: "css-loader", loader: "css-loader",