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(`