From d1379c55f62d7490f0e7407dc4a555159b771d74 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Nov 2025 10:47:48 +0100 Subject: [PATCH] :sparkles: Make i18n translation files load on demand --- CHANGES.md | 2 + frontend/.storybook/preview.js | 5 + frontend/resources/templates/index.mustache | 1 - .../resources/templates/preview-head.mustache | 4 - frontend/scripts/_helpers.js | 46 ++----- frontend/scripts/build-app-assets.js | 1 + frontend/scripts/build-storybook-assets.js | 4 + frontend/scripts/watch.js | 3 +- frontend/src/app/config.cljs | 1 - frontend/src/app/main.cljs | 9 +- frontend/src/app/main/data/event.cljs | 6 +- frontend/src/app/main/data/profile.cljs | 7 +- frontend/src/app/main/ui/ds.cljs | 8 +- frontend/src/app/util/i18n.cljs | 129 +++++++++++------- frontend/vite.config.js | 1 + 15 files changed, 124 insertions(+), 103 deletions(-) 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/"), }, }, });