diff --git a/CHANGES.md b/CHANGES.md
index 270e08fe55..3ff0053c81 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -12,6 +12,8 @@
### :sparkles: New features & Enhancements
+- Make i18n translation files load on-demand [Taiga #11474](https://tree.taiga.io/project/penpot/us/11474)
+
### :bug: Bugs fixed
- Fix problem when drag+duplicate a full grid [Taiga #12565](https://tree.taiga.io/project/penpot/issue/12565)
diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js
index a69e407c39..0345c004b8 100644
--- a/frontend/.storybook/preview.js
+++ b/frontend/.storybook/preview.js
@@ -1,5 +1,10 @@
import { withThemeByClassName } from "@storybook/addon-themes";
+
+import Components from "@target/components";
+import translations from "@public/translation.en.js";
+Components.setDefaultTranslations(translations);
+
import '../resources/public/css/ds.css';
export const decorators = [
diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache
index 66a4a05539..550120ee93 100644
--- a/frontend/resources/templates/index.mustache
+++ b/frontend/resources/templates/index.mustache
@@ -30,7 +30,6 @@
{{/manifest}}
diff --git a/frontend/resources/templates/preview-head.mustache b/frontend/resources/templates/preview-head.mustache
index 5ac5451b37..740482ff84 100644
--- a/frontend/resources/templates/preview-head.mustache
+++ b/frontend/resources/templates/preview-head.mustache
@@ -9,7 +9,3 @@
height: 100%;
}
-
-
diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js
index 72f9eee631..2469db6fa0 100644
--- a/frontend/scripts/_helpers.js
+++ b/frontend/scripts/_helpers.js
@@ -257,7 +257,7 @@ const markedOptions = {
marked.use(markedOptions);
-async function readTranslations() {
+export async function compileTranslations() {
const langs = [
"ar",
"ca",
@@ -294,9 +294,10 @@ async function readTranslations() {
["uk", "ukr_UA"],
"ha",
];
- const result = {};
for (let lang of langs) {
+ const result = {};
+
let filename = `${lang}.po`;
if (l.isArray(lang)) {
filename = `${lang[1]}.po`;
@@ -315,11 +316,6 @@ async function readTranslations() {
for (let key of Object.keys(trdata)) {
if (key === "") continue;
const comments = trdata[key].comments || {};
-
- if (l.isNil(result[key])) {
- result[key] = {};
- }
-
const isMarkdown = l.includes(comments.flag, "markdown");
const msgs = trdata[key].msgstr;
@@ -329,9 +325,9 @@ async function readTranslations() {
message = marked.parseInline(message);
}
- result[key][lang] = message;
+ result[key] = message;
} else {
- result[key][lang] = msgs.map((item) => {
+ result[key] = msgs.map((item) => {
if (isMarkdown) {
return marked.parseInline(item);
} else {
@@ -340,22 +336,12 @@ async function readTranslations() {
});
}
}
+
+ const esm = `export default ${JSON.stringify(result, null, 0)};\n`;
+ const outputDir = "resources/public/js/";
+ const outputFile = ph.join(outputDir, "translation." + lang + ".js");
+ await fs.writeFile(outputFile, esm);
}
-
- return result;
-}
-
-function filterTranslations(translations, langs = [], keyFilter) {
- const filteredEntries = Object.entries(translations)
- .filter(([translationKey, _]) => keyFilter(translationKey))
- .map(([translationKey, value]) => {
- const langEntries = Object.entries(value).filter(([lang, _]) =>
- langs.includes(lang),
- );
- return [translationKey, Object.fromEntries(langEntries)];
- });
-
- return Object.fromEntries(filteredEntries);
}
async function generateSvgSprite(files, prefix) {
@@ -407,14 +393,6 @@ async function generateTemplates() {
const isDebug = process.env.NODE_ENV !== "production";
await fs.mkdir("./resources/public/", { recursive: true });
- let translations = await readTranslations();
- const storybookTranslations = JSON.stringify(
- filterTranslations(translations, ["en"], (key) =>
- key.startsWith("labels."),
- ),
- );
- translations = JSON.stringify(translations);
-
const manifest = await readShadowManifest();
let content;
@@ -440,7 +418,6 @@ async function generateTemplates() {
"resources/templates/index.mustache",
{
manifest: manifest,
- translations: JSON.stringify(translations),
isDebug,
},
partials,
@@ -468,7 +445,6 @@ async function generateTemplates() {
"resources/templates/preview-head.mustache",
{
manifest: manifest,
- translations: JSON.stringify(storybookTranslations),
},
partials,
);
@@ -476,14 +452,12 @@ async function generateTemplates() {
content = await renderTemplate("resources/templates/render.mustache", {
manifest: manifest,
- translations: JSON.stringify(translations),
});
await fs.writeFile("./resources/public/render.html", content);
content = await renderTemplate("resources/templates/rasterizer.mustache", {
manifest: manifest,
- translations: JSON.stringify(translations),
});
await fs.writeFile("./resources/public/rasterizer.html", content);
diff --git a/frontend/scripts/build-app-assets.js b/frontend/scripts/build-app-assets.js
index 5008d9a098..6ccc435839 100644
--- a/frontend/scripts/build-app-assets.js
+++ b/frontend/scripts/build-app-assets.js
@@ -4,5 +4,6 @@ await h.compileStyles();
await h.copyAssets();
await h.copyWasmPlayground();
await h.compileSvgSprites();
+await h.compileTranslations();
await h.compileTemplates();
await h.compilePolyfills();
diff --git a/frontend/scripts/build-storybook-assets.js b/frontend/scripts/build-storybook-assets.js
index c0eb37a36f..092762ceb6 100644
--- a/frontend/scripts/build-storybook-assets.js
+++ b/frontend/scripts/build-storybook-assets.js
@@ -1,7 +1,11 @@
+import fs from "node:fs/promises";
import * as h from "./_helpers.js";
+await fs.mkdir("resources/public/js", {recursive: true});
+
await h.compileStorybookStyles();
await h.copyAssets();
await h.compileSvgSprites();
+await h.compileTranslations();
await h.compileTemplates();
await h.compilePolyfills();
diff --git a/frontend/scripts/watch.js b/frontend/scripts/watch.js
index 57e9be6016..e2d9ff399d 100644
--- a/frontend/scripts/watch.js
+++ b/frontend/scripts/watch.js
@@ -52,6 +52,7 @@ await fs.mkdir("./resources/public/css/", { recursive: true });
await compileSassAll();
await h.copyAssets();
await h.copyWasmPlayground();
+await h.compileTranslations();
await h.compileSvgSprites();
await h.compileTemplates();
await h.compilePolyfills();
@@ -81,7 +82,7 @@ h.watch("resources/templates", null, async function (path) {
log.info("watch: translations (~)");
h.watch("translations", null, async function (path) {
log.info("changed:", path);
- await h.compileTemplates();
+ await h.compileTranslations();
});
log.info("watch: assets (~)");
diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs
index 76879756c7..4fb2d171c7 100644
--- a/frontend/src/app/config.cljs
+++ b/frontend/src/app/config.cljs
@@ -86,7 +86,6 @@
(def default-theme "default")
(def default-language "en")
-(def translations (obj/get global "penpotTranslations"))
(def themes (obj/get global "penpotThemes"))
(def build-date (parse-build-date global))
diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs
index d027e0ba57..f40e8b3b18 100644
--- a/frontend/src/app/main.cljs
+++ b/frontend/src/app/main.cljs
@@ -92,7 +92,7 @@
(defn ^:export init
[]
(mw/init!)
- (i18n/init! cf/translations)
+ (i18n/init)
(cur/init-styles)
(thr/init!)
(init-ui)
@@ -114,11 +114,4 @@
[]
(reinit))
-;; Reload the UI when the language changes
-(add-watch
- i18n/locale "locale"
- (fn [_ _ old-value current-value]
- (when (not= old-value current-value)
- (reinit))))
-
(set! (.-stackTraceLimit js/Error) 50)
diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs
index dfba7479ef..0e6ac46b4a 100644
--- a/frontend/src/app/main/data/event.cljs
+++ b/frontend/src/app/main/data/event.cljs
@@ -68,7 +68,7 @@
(let [uagent (new ua/UAParser)]
(merge
{:app-version (:full cf/version)
- :locale @i18n/locale}
+ :locale i18n/*current-locale*}
(let [browser (.getBrowser uagent)]
{:browser (obj/get browser "name")
:browser-version (obj/get browser "version")})
@@ -98,7 +98,9 @@
(def context
(atom (d/without-nils (collect-context))))
-(add-watch i18n/locale ::events #(swap! context assoc :locale %4))
+(add-watch i18n/state "events"
+ (fn [_ _ _ v]
+ (swap! context assoc :locale (get v :locale))))
;; --- EVENT TRANSLATION
diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs
index 127567f7b9..e7828a0302 100644
--- a/frontend/src/app/main/data/profile.cljs
+++ b/frontend/src/app/main/data/profile.cljs
@@ -53,11 +53,16 @@
(assoc :profile-id id)
(assoc :profile profile)))
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [profile (:profile state)]
+ (->> (rx/from (i18n/set-locale (:lang profile)))
+ (rx/ignore))))
+
ptk/EffectEvent
(effect [_ state _]
(let [profile (:profile state)]
(swap! storage/user assoc :profile profile)
- (i18n/set-locale! (:lang profile))
(plugins.register/init)))))
(def profile-fetched?
diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index 147a34bfa4..05e904d8cb 100644
--- a/frontend/src/app/main/ui/ds.cljs
+++ b/frontend/src/app/main/ui/ds.cljs
@@ -6,7 +6,6 @@
(ns app.main.ui.ds
(:require
- [app.config :as cf]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.checkbox :refer [checkbox*]]
@@ -44,8 +43,6 @@
[app.util.i18n :as i18n]
[rumext.v2 :as mf]))
-(i18n/init! cf/translations)
-
(def default
"A export used for storybook"
(mf/object
@@ -80,6 +77,11 @@
:Milestone milestone*
:MilestoneGroup milestone-group*
:Date date*
+
+ :set-default-translations
+ (fn [data]
+ (i18n/set-translations "en" data))
+
;; meta / misc
:meta
{:icons (clj->js (sort icon-list))
diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs
index 5560586d31..69b90a89f1 100644
--- a/frontend/src/app/util/i18n.cljs
+++ b/frontend/src/app/util/i18n.cljs
@@ -10,12 +10,14 @@
[app.common.data :as d]
[app.common.logging :as log]
[app.common.time :as ct]
- [app.config :as cfg]
+ [app.config :as cf]
[app.util.globals :as globals]
+ [app.util.modules :as mod]
[app.util.storage :as storage]
[cuerdas.core :as str]
[goog.object :as gobj]
[okulary.core :as l]
+ [promesa.core :as p]
[rumext.v2 :as mf]))
(log/set-level! :info)
@@ -67,6 +69,12 @@
(-> (.-language globals/navigator)
(parse-locale))))
+
+;; Set initial translation loading state as globaly stored variable;
+;; this facilitates hot reloading
+(when-not (exists? (unchecked-get globals/global "penpotTranslations"))
+ (unchecked-set globals/global "penpotTranslations" #js {}))
+
(defn- autodetect
[]
(let [supported (into #{} (map :value supported-locales))]
@@ -75,43 +83,69 @@
(if (contains? supported locale)
locale
(recur (rest locales)))
- cfg/default-language))))
+ cf/default-language))))
-(defonce translations #js {})
-(defonce locale (l/atom nil))
+(defn get-current
+ "Get the currently memoized locale or execute the autodetection"
+ []
+ (or (get storage/global ::locale) (autodetect)))
-(add-watch locale "common.time"
- (fn [_ _ pv cv]
- (when (not= pv cv)
- (ct/set-default-locale! cv))))
+(def ^:dynamic *current-locale*
+ (get-current))
-(defn init!
- "Initialize the i18n module with translations.
+(defonce state
+ (l/atom {:render 0 :locale *current-locale*}))
- The `data` is a javascript object for performance reasons. This code
- is executed in the critical part (application bootstrap) and used in
- many parts of the application."
- [data]
- (set! translations data)
- (reset! locale (or (get storage/global ::locale) (autodetect))))
+(defn- assign-current-locale
+ [state locale]
+ (-> state
+ (update :render inc)
+ (assoc :locale locale)))
+(defn- get-translations
+ "Get globaly stored mutable object with all loaded translations"
+ []
+ (unchecked-get globals/global "penpotTranslations"))
-(defn set-locale!
+(defn set-translations
+ "A helper for synchronously set translations data for specified locale"
+ [locale data]
+ (let [translations (get-translations)]
+ (unchecked-set translations locale data)
+ nil))
+
+(defn- load
+ [locale]
+ (let [path (str "./translation." locale ".js")]
+ (->> (mod/import path)
+ (p/fmap (fn [result] (unchecked-get result "default")))
+ (p/fnly (fn [data cause]
+ (if cause
+ (js/console.error "unexpected error on fetching locale" cause)
+ (do
+ (set! *current-locale* locale)
+ (set-translations locale data)
+ (swap! state assign-current-locale locale))))))))
+
+(defn init
+ "Initialize the i18n module"
+ []
+ (load *current-locale*))
+
+(defn set-locale
[lname]
- (if (or (nil? lname)
- (str/empty? lname))
- (let [lname (autodetect)]
- (swap! storage/global dissoc ::locale)
- (reset! locale lname))
- (let [supported (into #{} (map :value) supported-locales)
- lname (loop [locales (seq (parse-locale lname))]
- (if-let [locale (first locales)]
- (if (contains? supported locale)
- locale
- (recur (rest locales)))
- cfg/default-language))]
- (swap! storage/global assoc ::locale lname)
- (reset! locale lname))))
+ (let [lname (if (or (nil? lname)
+ (str/empty? lname))
+ (autodetect)
+ (let [supported (into #{} (map :value) supported-locales)]
+ (loop [locales (seq (parse-locale lname))]
+ (if-let [locale (first locales)]
+ (if (contains? supported locale)
+ locale
+ (recur (rest locales)))
+ cf/default-language))))]
+
+ (load lname)))
(deftype C [val]
IDeref
@@ -136,22 +170,24 @@
(defn t
([locale code]
- (let [code (name code)
- value (gobj/getValueByKeys translations code locale)]
+ (let [translations (get-translations)
+ code (d/name code)
+ value (gobj/getValueByKeys translations locale code)]
(if (empty-string? value)
- (if (= cfg/default-language locale)
+ (if (= cf/default-language locale)
code
- (t cfg/default-language code))
+ (t cf/default-language code))
(if (array? value)
(aget value 0)
value))))
([locale code & args]
- (let [code (name code)
- value (gobj/getValueByKeys translations code locale)]
+ (let [translations (get-translations)
+ code (d/name code)
+ value (gobj/getValueByKeys translations locale code)]
(if (empty-string? value)
- (if (= cfg/default-language locale)
+ (if (= cf/default-language locale)
code
- (apply t cfg/default-language code args))
+ (apply t cf/default-language code args))
(let [plural (first (filter c? args))
value (if (array? value)
(if (= @plural 1) (aget value 0) (aget value 1))
@@ -159,8 +195,8 @@
(apply str/fmt value (map #(if (c? %) @% %) args)))))))
(defn tr
- ([code] (t @locale code))
- ([code & args] (apply t @locale code args)))
+ ([code] (t *current-locale* code))
+ ([code & args] (apply t *current-locale* code args)))
(mf/defc tr-html*
{::mf/props :obj}
@@ -170,8 +206,9 @@
:className class
:on-click on-click}]))
-;; DEPRECATED
-(defn use-locale
- []
- (mf/deref locale))
-
+(add-watch state "common.time"
+ (fn [_ _ pv cv]
+ (let [pv (get pv :locale)
+ cv (get cv :locale)]
+ (when (not= pv cv)
+ (ct/set-default-locale! cv)))))
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 4896f667bb..bf815d2368 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -47,6 +47,7 @@ export default defineConfig({
resolve: {
alias: {
"@target": resolve(__dirname, "./target/storybook"),
+ "@public": resolve(__dirname, "./resources/public/js/"),
},
},
});