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
- 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)

View File

@@ -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 = [

View File

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

View File

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

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 (~)");

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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?

View File

@@ -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))

View File

@@ -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)
(let [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))]
(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)))
cfg/default-language))]
(swap! storage/global assoc ::locale lname)
(reset! locale lname))))
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)))))

View File

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