Make i18n translation files load on demand

This commit is contained in:
Andrey Antukh
2025-11-25 10:47:48 +01:00
committed by Belén Albeza
parent b125c7b5a3
commit d1379c55f6
15 changed files with 124 additions and 103 deletions

View File

@@ -12,6 +12,8 @@
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements
- Make i18n translation files load on-demand [Taiga #11474](https://tree.taiga.io/project/penpot/us/11474)
### :bug: Bugs fixed ### :bug: Bugs fixed
- Fix problem when drag+duplicate a full grid [Taiga #12565](https://tree.taiga.io/project/penpot/issue/12565) - Fix problem when drag+duplicate a full grid [Taiga #12565](https://tree.taiga.io/project/penpot/issue/12565)

View File

@@ -1,5 +1,10 @@
import { withThemeByClassName } from "@storybook/addon-themes"; 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'; import '../resources/public/css/ds.css';
export const decorators = [ export const decorators = [

View File

@@ -30,7 +30,6 @@
{{/manifest}} {{/manifest}}
<script type="module"> <script type="module">
globalThis.penpotTranslations = JSON.parse({{& translations}});
globalThis.penpotVersion = "%version%"; globalThis.penpotVersion = "%version%";
globalThis.penpotBuildDate = "%buildDate%"; globalThis.penpotBuildDate = "%buildDate%";
</script> </script>

View File

@@ -9,7 +9,3 @@
height: 100%; height: 100%;
} }
</style> </style>
<script>
window.penpotTranslations = JSON.parse({{& translations}});
</script>

View File

@@ -257,7 +257,7 @@ const markedOptions = {
marked.use(markedOptions); marked.use(markedOptions);
async function readTranslations() { export async function compileTranslations() {
const langs = [ const langs = [
"ar", "ar",
"ca", "ca",
@@ -294,9 +294,10 @@ async function readTranslations() {
["uk", "ukr_UA"], ["uk", "ukr_UA"],
"ha", "ha",
]; ];
const result = {};
for (let lang of langs) { for (let lang of langs) {
const result = {};
let filename = `${lang}.po`; let filename = `${lang}.po`;
if (l.isArray(lang)) { if (l.isArray(lang)) {
filename = `${lang[1]}.po`; filename = `${lang[1]}.po`;
@@ -315,11 +316,6 @@ async function readTranslations() {
for (let key of Object.keys(trdata)) { for (let key of Object.keys(trdata)) {
if (key === "") continue; if (key === "") continue;
const comments = trdata[key].comments || {}; const comments = trdata[key].comments || {};
if (l.isNil(result[key])) {
result[key] = {};
}
const isMarkdown = l.includes(comments.flag, "markdown"); const isMarkdown = l.includes(comments.flag, "markdown");
const msgs = trdata[key].msgstr; const msgs = trdata[key].msgstr;
@@ -329,9 +325,9 @@ async function readTranslations() {
message = marked.parseInline(message); message = marked.parseInline(message);
} }
result[key][lang] = message; result[key] = message;
} else { } else {
result[key][lang] = msgs.map((item) => { result[key] = msgs.map((item) => {
if (isMarkdown) { if (isMarkdown) {
return marked.parseInline(item); return marked.parseInline(item);
} else { } 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) { async function generateSvgSprite(files, prefix) {
@@ -407,14 +393,6 @@ async function generateTemplates() {
const isDebug = process.env.NODE_ENV !== "production"; const isDebug = process.env.NODE_ENV !== "production";
await fs.mkdir("./resources/public/", { recursive: true }); 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(); const manifest = await readShadowManifest();
let content; let content;
@@ -440,7 +418,6 @@ async function generateTemplates() {
"resources/templates/index.mustache", "resources/templates/index.mustache",
{ {
manifest: manifest, manifest: manifest,
translations: JSON.stringify(translations),
isDebug, isDebug,
}, },
partials, partials,
@@ -468,7 +445,6 @@ async function generateTemplates() {
"resources/templates/preview-head.mustache", "resources/templates/preview-head.mustache",
{ {
manifest: manifest, manifest: manifest,
translations: JSON.stringify(storybookTranslations),
}, },
partials, partials,
); );
@@ -476,14 +452,12 @@ async function generateTemplates() {
content = await renderTemplate("resources/templates/render.mustache", { content = await renderTemplate("resources/templates/render.mustache", {
manifest: manifest, manifest: manifest,
translations: JSON.stringify(translations),
}); });
await fs.writeFile("./resources/public/render.html", content); await fs.writeFile("./resources/public/render.html", content);
content = await renderTemplate("resources/templates/rasterizer.mustache", { content = await renderTemplate("resources/templates/rasterizer.mustache", {
manifest: manifest, manifest: manifest,
translations: JSON.stringify(translations),
}); });
await fs.writeFile("./resources/public/rasterizer.html", content); await fs.writeFile("./resources/public/rasterizer.html", content);

View File

@@ -4,5 +4,6 @@ await h.compileStyles();
await h.copyAssets(); await h.copyAssets();
await h.copyWasmPlayground(); await h.copyWasmPlayground();
await h.compileSvgSprites(); await h.compileSvgSprites();
await h.compileTranslations();
await h.compileTemplates(); await h.compileTemplates();
await h.compilePolyfills(); await h.compilePolyfills();

View File

@@ -1,7 +1,11 @@
import fs from "node:fs/promises";
import * as h from "./_helpers.js"; import * as h from "./_helpers.js";
await fs.mkdir("resources/public/js", {recursive: true});
await h.compileStorybookStyles(); await h.compileStorybookStyles();
await h.copyAssets(); await h.copyAssets();
await h.compileSvgSprites(); await h.compileSvgSprites();
await h.compileTranslations();
await h.compileTemplates(); await h.compileTemplates();
await h.compilePolyfills(); await h.compilePolyfills();

View File

@@ -52,6 +52,7 @@ await fs.mkdir("./resources/public/css/", { recursive: true });
await compileSassAll(); await compileSassAll();
await h.copyAssets(); await h.copyAssets();
await h.copyWasmPlayground(); await h.copyWasmPlayground();
await h.compileTranslations();
await h.compileSvgSprites(); await h.compileSvgSprites();
await h.compileTemplates(); await h.compileTemplates();
await h.compilePolyfills(); await h.compilePolyfills();
@@ -81,7 +82,7 @@ h.watch("resources/templates", null, async function (path) {
log.info("watch: translations (~)"); log.info("watch: translations (~)");
h.watch("translations", null, async function (path) { h.watch("translations", null, async function (path) {
log.info("changed:", path); log.info("changed:", path);
await h.compileTemplates(); await h.compileTranslations();
}); });
log.info("watch: assets (~)"); log.info("watch: assets (~)");

View File

@@ -86,7 +86,6 @@
(def default-theme "default") (def default-theme "default")
(def default-language "en") (def default-language "en")
(def translations (obj/get global "penpotTranslations"))
(def themes (obj/get global "penpotThemes")) (def themes (obj/get global "penpotThemes"))
(def build-date (parse-build-date global)) (def build-date (parse-build-date global))

View File

@@ -92,7 +92,7 @@
(defn ^:export init (defn ^:export init
[] []
(mw/init!) (mw/init!)
(i18n/init! cf/translations) (i18n/init)
(cur/init-styles) (cur/init-styles)
(thr/init!) (thr/init!)
(init-ui) (init-ui)
@@ -114,11 +114,4 @@
[] []
(reinit)) (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) (set! (.-stackTraceLimit js/Error) 50)

View File

@@ -68,7 +68,7 @@
(let [uagent (new ua/UAParser)] (let [uagent (new ua/UAParser)]
(merge (merge
{:app-version (:full cf/version) {:app-version (:full cf/version)
:locale @i18n/locale} :locale i18n/*current-locale*}
(let [browser (.getBrowser uagent)] (let [browser (.getBrowser uagent)]
{:browser (obj/get browser "name") {:browser (obj/get browser "name")
:browser-version (obj/get browser "version")}) :browser-version (obj/get browser "version")})
@@ -98,7 +98,9 @@
(def context (def context
(atom (d/without-nils (collect-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 ;; --- EVENT TRANSLATION

View File

@@ -53,11 +53,16 @@
(assoc :profile-id id) (assoc :profile-id id)
(assoc :profile profile))) (assoc :profile profile)))
ptk/WatchEvent
(watch [_ state _]
(let [profile (:profile state)]
(->> (rx/from (i18n/set-locale (:lang profile)))
(rx/ignore))))
ptk/EffectEvent ptk/EffectEvent
(effect [_ state _] (effect [_ state _]
(let [profile (:profile state)] (let [profile (:profile state)]
(swap! storage/user assoc :profile profile) (swap! storage/user assoc :profile profile)
(i18n/set-locale! (:lang profile))
(plugins.register/init))))) (plugins.register/init)))))
(def profile-fetched? (def profile-fetched?

View File

@@ -6,7 +6,6 @@
(ns app.main.ui.ds (ns app.main.ui.ds
(:require (:require
[app.config :as cf]
[app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.checkbox :refer [checkbox*]] [app.main.ui.ds.controls.checkbox :refer [checkbox*]]
@@ -44,8 +43,6 @@
[app.util.i18n :as i18n] [app.util.i18n :as i18n]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(i18n/init! cf/translations)
(def default (def default
"A export used for storybook" "A export used for storybook"
(mf/object (mf/object
@@ -80,6 +77,11 @@
:Milestone milestone* :Milestone milestone*
:MilestoneGroup milestone-group* :MilestoneGroup milestone-group*
:Date date* :Date date*
:set-default-translations
(fn [data]
(i18n/set-translations "en" data))
;; meta / misc ;; meta / misc
:meta :meta
{:icons (clj->js (sort icon-list)) {:icons (clj->js (sort icon-list))

View File

@@ -10,12 +10,14 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.time :as ct] [app.common.time :as ct]
[app.config :as cfg] [app.config :as cf]
[app.util.globals :as globals] [app.util.globals :as globals]
[app.util.modules :as mod]
[app.util.storage :as storage] [app.util.storage :as storage]
[cuerdas.core :as str] [cuerdas.core :as str]
[goog.object :as gobj] [goog.object :as gobj]
[okulary.core :as l] [okulary.core :as l]
[promesa.core :as p]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(log/set-level! :info) (log/set-level! :info)
@@ -67,6 +69,12 @@
(-> (.-language globals/navigator) (-> (.-language globals/navigator)
(parse-locale)))) (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 (defn- autodetect
[] []
(let [supported (into #{} (map :value supported-locales))] (let [supported (into #{} (map :value supported-locales))]
@@ -75,43 +83,69 @@
(if (contains? supported locale) (if (contains? supported locale)
locale locale
(recur (rest locales))) (recur (rest locales)))
cfg/default-language)))) cf/default-language))))
(defonce translations #js {}) (defn get-current
(defonce locale (l/atom nil)) "Get the currently memoized locale or execute the autodetection"
[]
(or (get storage/global ::locale) (autodetect)))
(add-watch locale "common.time" (def ^:dynamic *current-locale*
(fn [_ _ pv cv] (get-current))
(when (not= pv cv)
(ct/set-default-locale! cv))))
(defn init! (defonce state
"Initialize the i18n module with translations. (l/atom {:render 0 :locale *current-locale*}))
The `data` is a javascript object for performance reasons. This code (defn- assign-current-locale
is executed in the critical part (application bootstrap) and used in [state locale]
many parts of the application." (-> state
[data] (update :render inc)
(set! translations data) (assoc :locale locale)))
(reset! locale (or (get storage/global ::locale) (autodetect))))
(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] [lname]
(if (or (nil? lname) (let [lname (if (or (nil? lname)
(str/empty? lname)) (str/empty? lname))
(let [lname (autodetect)] (autodetect)
(swap! storage/global dissoc ::locale) (let [supported (into #{} (map :value) supported-locales)]
(reset! locale lname)) (loop [locales (seq (parse-locale lname))]
(let [supported (into #{} (map :value) supported-locales) (if-let [locale (first locales)]
lname (loop [locales (seq (parse-locale lname))] (if (contains? supported locale)
(if-let [locale (first locales)] locale
(if (contains? supported locale) (recur (rest locales)))
locale cf/default-language))))]
(recur (rest locales)))
cfg/default-language))] (load lname)))
(swap! storage/global assoc ::locale lname)
(reset! locale lname))))
(deftype C [val] (deftype C [val]
IDeref IDeref
@@ -136,22 +170,24 @@
(defn t (defn t
([locale code] ([locale code]
(let [code (name code) (let [translations (get-translations)
value (gobj/getValueByKeys translations code locale)] code (d/name code)
value (gobj/getValueByKeys translations locale code)]
(if (empty-string? value) (if (empty-string? value)
(if (= cfg/default-language locale) (if (= cf/default-language locale)
code code
(t cfg/default-language code)) (t cf/default-language code))
(if (array? value) (if (array? value)
(aget value 0) (aget value 0)
value)))) value))))
([locale code & args] ([locale code & args]
(let [code (name code) (let [translations (get-translations)
value (gobj/getValueByKeys translations code locale)] code (d/name code)
value (gobj/getValueByKeys translations locale code)]
(if (empty-string? value) (if (empty-string? value)
(if (= cfg/default-language locale) (if (= cf/default-language locale)
code code
(apply t cfg/default-language code args)) (apply t cf/default-language code args))
(let [plural (first (filter c? args)) (let [plural (first (filter c? args))
value (if (array? value) value (if (array? value)
(if (= @plural 1) (aget value 0) (aget value 1)) (if (= @plural 1) (aget value 0) (aget value 1))
@@ -159,8 +195,8 @@
(apply str/fmt value (map #(if (c? %) @% %) args))))))) (apply str/fmt value (map #(if (c? %) @% %) args)))))))
(defn tr (defn tr
([code] (t @locale code)) ([code] (t *current-locale* code))
([code & args] (apply t @locale code args))) ([code & args] (apply t *current-locale* code args)))
(mf/defc tr-html* (mf/defc tr-html*
{::mf/props :obj} {::mf/props :obj}
@@ -170,8 +206,9 @@
:className class :className class
:on-click on-click}])) :on-click on-click}]))
;; DEPRECATED (add-watch state "common.time"
(defn use-locale (fn [_ _ pv cv]
[] (let [pv (get pv :locale)
(mf/deref locale)) cv (get cv :locale)]
(when (not= pv cv)
(ct/set-default-locale! cv)))))

View File

@@ -47,6 +47,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@target": resolve(__dirname, "./target/storybook"), "@target": resolve(__dirname, "./target/storybook"),
"@public": resolve(__dirname, "./resources/public/js/"),
}, },
}, },
}); });