diff --git a/src/gui/src/appsEmbed.js b/src/gui/src/appsEmbed.js
index bd18b0d50..71fc4a28a 100644
--- a/src/gui/src/appsEmbed.js
+++ b/src/gui/src/appsEmbed.js
@@ -1,6 +1,6 @@
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
import { encode } from 'html-entities';
-import './i18n/i18n.js';
+import { loadLocale } from './i18n/embedI18n.js';
import { createAppBrowser } from './UI/Dashboard/AppBrowser.js';
import { appTileLink } from './UI/Dashboard/appLink.js';
import { installAppIconFallback } from './helpers/appIcon.js';
@@ -21,23 +21,22 @@ window.gui_origin = window.location.origin;
window.puter_gui_enabled = true;
window.puter_socket_enabled = false;
window.locale = 'en';
+try {
+ window.locale = JSON.parse(localStorage.getItem('user_preferences') || '{}')?.language || 'en';
+} catch { /* Corrupt preferences must not prevent loading apps. */ }
+const localeReady = loadLocale(window.locale);
async function mount () {
window.auth_token = localStorage.getItem('auth_token_v2');
if ( ! window.auth_token ) throw new Error('Missing session');
- let preferences;
- try {
- preferences = JSON.parse(localStorage.getItem('user_preferences') || '{}');
- } catch { /* Corrupt preferences must not prevent loading apps. */ }
- window.locale = preferences?.language || 'en';
- await new Promise((resolve, reject) => {
+ await Promise.all([new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = config.sdkUrl;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
- });
+ }), localeReady]);
puter.setAPIOrigin(config.apiOrigin);
installAppIconFallback();
@@ -53,6 +52,7 @@ async function mount () {
browser.init($root);
}
-mount().catch(() => {
+mount().catch(async () => {
+ await localeReady;
root.innerHTML = `
${i18n('app_browser_unavailable')}
`;
});
diff --git a/src/gui/src/i18n/embedI18n.js b/src/gui/src/i18n/embedI18n.js
new file mode 100644
index 000000000..7c768acc0
--- /dev/null
+++ b/src/gui/src/i18n/embedI18n.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import en from './translations/en.js';
+import { installI18n } from './i18nCore.js';
+
+installI18n({ en });
+
+/**
+ * Loads only the preferred language, so embeds don't ship every translation.
+ * Unknown or unavailable languages keep English.
+ * @param {string} locale
+ */
+export async function loadLocale (locale) {
+ if ( typeof locale !== 'string' || locale === 'en' || ! /^[a-z]+$/.test(locale) ) return;
+ try {
+ const { default: language } = await import(
+ /* webpackExclude: /translations\.js$/ */ `./translations/${locale}.js`
+ );
+ installI18n({ en, [locale]: language });
+ } catch {
+ // English is already installed.
+ }
+}
diff --git a/src/gui/src/i18n/embedI18n.test.js b/src/gui/src/i18n/embedI18n.test.js
new file mode 100644
index 000000000..63c6d746f
--- /dev/null
+++ b/src/gui/src/i18n/embedI18n.test.js
@@ -0,0 +1,26 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+
+beforeAll(() => {
+ globalThis.window = globalThis;
+ globalThis.html_encode = value => String(value);
+});
+
+describe('embed translations', () => {
+ it('starts in English and loads only the requested language', async () => {
+ const { loadLocale } = await import('./embedI18n.js');
+ window.locale = 'de';
+ expect(i18n('close')).toBe('Close');
+ await loadLocale('de');
+ expect(i18n('close')).toBe('Schließen');
+ expect(i18n('toolbar_apps')).toBe('Your apps');
+ });
+
+ it('keeps English for unknown or unsafe language values', async () => {
+ const { loadLocale } = await import('./embedI18n.js');
+ for ( const locale of ['xx', '../translations', 'en', null] ) {
+ window.locale = locale;
+ await loadLocale(locale);
+ expect(i18n('close')).toBe('Close');
+ }
+ });
+});
diff --git a/src/gui/src/i18n/i18n.js b/src/gui/src/i18n/i18n.js
index 015beb74f..8cea04b2c 100644
--- a/src/gui/src/i18n/i18n.js
+++ b/src/gui/src/i18n/i18n.js
@@ -18,72 +18,10 @@
*/
import translations from './translations/translations.js';
+import { installI18n } from './i18nCore.js';
window.listSupportedLanguages = () => Object.keys(translations).map(lang => translations[lang]);
-const variables = {
- docs: 'https://docs.puter.com/',
- terms: 'https://puter.com/terms',
- privacy: 'https://puter.com/privacy',
-};
+installI18n(translations);
-function ReplacePlaceholders (str, arg_variables = {}) {
- const all_variables = { ...variables, ...arg_variables };
- str = str.replace(/{{link=(.*?)}}(.*?){{\/link}}/g, (_, key, text) => `${text}`);
- str = str.replace(/{{(.*?)}}/g, (_, key) => all_variables[key]);
- return str;
-}
-
-window.i18n = function (key, replacements = [], encode_html = true) {
- let arg_variables = {};
- if ( Array.isArray(replacements) === false ) {
- if ( typeof replacements === 'object' ) {
- arg_variables = replacements;
- replacements = [];
- } else {
- replacements = [replacements];
- }
- }
-
- let language = translations[window.locale] ?? translations['en'];
- let str = language.dictionary[key] ?? translations['en'].dictionary[key];
-
- if ( ! str ) {
- str = key;
- }
- str = ReplacePlaceholders(str, arg_variables);
- if ( encode_html ) {
- str = html_encode(str);
- // html_encode doesn't render line breaks
- str = str.replace(/\n/g, '
');
- }
- // replace %% occurrences with the values in replacements
- // %% is for simple text replacements
- // %strong% is for tags
- // e.g. "Hello, %strong%" => "Hello, World"
- // e.g. "Hello, %%" => "Hello, World"
- // e.g. "Hello, %strong%, %%!" => "Hello, World, Universe!"
- for ( let i = 0; i < replacements.length; i++ ) {
- // sanitize the replacement
- replacements[i] = encode_html ? html_encode(replacements[i]) : replacements[i];
- // find first occurrence of %strong%
- let index = str.indexOf('%strong%');
- // find first occurrence of %%
- let index2 = str.indexOf('%%');
- // decide which one to replace
- if ( index === -1 && index2 === -1 ) {
- break;
- } else if ( index === -1 ) {
- str = str.replace('%%', replacements[i]);
- } else if ( index2 === -1 ) {
- str = str.replace('%strong%', `${ replacements[i] }`);
- } else if ( index < index2 ) {
- str = str.replace('%strong%', `${ replacements[i] }`);
- } else {
- str = str.replace('%%', replacements[i]);
- }
- }
- return str;
-};
-
-export default {};
\ No newline at end of file
+export default {};
diff --git a/src/gui/src/i18n/i18nCore.js b/src/gui/src/i18n/i18nCore.js
new file mode 100644
index 000000000..905dea7f2
--- /dev/null
+++ b/src/gui/src/i18n/i18nCore.js
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+const variables = {
+ docs: 'https://docs.puter.com/',
+ terms: 'https://puter.com/terms',
+ privacy: 'https://puter.com/privacy',
+};
+
+function ReplacePlaceholders (str, arg_variables = {}) {
+ const all_variables = { ...variables, ...arg_variables };
+ str = str.replace(/{{link=(.*?)}}(.*?){{\/link}}/g, (_, key, text) => `${text}`);
+ str = str.replace(/{{(.*?)}}/g, (_, key) => all_variables[key]);
+ return str;
+}
+
+/**
+ * Installs `window.i18n` over the given language table, which must include `en`.
+ * @param {Record }>} translations
+ */
+export function installI18n (translations) {
+ window.i18n = function (key, replacements = [], encode_html = true) {
+ let arg_variables = {};
+ if ( Array.isArray(replacements) === false ) {
+ if ( typeof replacements === 'object' ) {
+ arg_variables = replacements;
+ replacements = [];
+ } else {
+ replacements = [replacements];
+ }
+ }
+
+ let language = translations[window.locale] ?? translations['en'];
+ let str = language.dictionary[key] ?? translations['en'].dictionary[key];
+
+ if ( ! str ) {
+ str = key;
+ }
+ str = ReplacePlaceholders(str, arg_variables);
+ if ( encode_html ) {
+ str = html_encode(str);
+ // html_encode doesn't render line breaks
+ str = str.replace(/\n/g, '
');
+ }
+ // replace %% occurrences with the values in replacements
+ // %% is for simple text replacements
+ // %strong% is for tags
+ // e.g. "Hello, %strong%" => "Hello, World"
+ // e.g. "Hello, %%" => "Hello, World"
+ // e.g. "Hello, %strong%, %%!" => "Hello, World, Universe!"
+ for ( let i = 0; i < replacements.length; i++ ) {
+ // sanitize the replacement
+ replacements[i] = encode_html ? html_encode(replacements[i]) : replacements[i];
+ // find first occurrence of %strong%
+ let index = str.indexOf('%strong%');
+ // find first occurrence of %%
+ let index2 = str.indexOf('%%');
+ // decide which one to replace
+ if ( index === -1 && index2 === -1 ) {
+ break;
+ } else if ( index === -1 ) {
+ str = str.replace('%%', replacements[i]);
+ } else if ( index2 === -1 ) {
+ str = str.replace('%strong%', `${ replacements[i] }`);
+ } else if ( index < index2 ) {
+ str = str.replace('%strong%', `${ replacements[i] }`);
+ } else {
+ str = str.replace('%%', replacements[i]);
+ }
+ }
+ return str;
+ };
+}
diff --git a/src/gui/src/toolbarEmbed.js b/src/gui/src/toolbarEmbed.js
index 2a5ec6e2a..8d80c7459 100644
--- a/src/gui/src/toolbarEmbed.js
+++ b/src/gui/src/toolbarEmbed.js
@@ -1,11 +1,12 @@
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
import { encode } from 'html-entities';
-import './i18n/i18n.js';
+import { loadLocale } from './i18n/embedI18n.js';
import { isToolbarParent, readSavedAccounts, shouldShowUpgrade, toolbarChannel } from './helpers/toolbarEmbed.js';
window.$ = window.jQuery = jQuery;
window.locale = 'en';
try { window.locale = JSON.parse(localStorage.getItem('user_preferences'))?.language || 'en'; } catch {}
+const localeReady = loadLocale(window.locale);
const config = JSON.parse(document.querySelector('#app-browser-config').textContent);
const $root = $('#puter-toolbar');
const escape = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
@@ -146,7 +147,7 @@ document.addEventListener('keydown', event => {
async function mount () {
if ( !sessionToken ) throw new Error('Missing session');
- user = await (await request('/whoami')).json();
+ [user] = await Promise.all([request('/whoami').then(response => response.json()), localeReady]);
if ( !user.uuid ) throw new Error('Missing account');
$root.html(`