From a338c8db46de83473517df61caa2159c908f87aa Mon Sep 17 00:00:00 2001 From: jelveh Date: Thu, 1 Oct 2026 22:06:12 -0700 Subject: [PATCH] Load only the preferred language in the toolbar and app browser embeds Importing i18n.js pulled every translation into each embed, making the toolbar bundle 1.1 MB. The embeds now bundle English and fetch the user's language on demand (toolbar 1.12 MB -> 179 KB, app browser 1.18 MB -> 237 KB). The desktop bundle keeps all translations. --- src/gui/src/appsEmbed.js | 18 +++--- src/gui/src/i18n/embedI18n.js | 40 ++++++++++++++ src/gui/src/i18n/embedI18n.test.js | 26 +++++++++ src/gui/src/i18n/i18n.js | 68 +---------------------- src/gui/src/i18n/i18nCore.js | 89 ++++++++++++++++++++++++++++++ src/gui/src/toolbarEmbed.js | 10 +++- 6 files changed, 174 insertions(+), 77 deletions(-) create mode 100644 src/gui/src/i18n/embedI18n.js create mode 100644 src/gui/src/i18n/embedI18n.test.js create mode 100644 src/gui/src/i18n/i18nCore.js 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(`