mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-11 14:21:51 +00:00
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.
This commit is contained in:
1 parent
ffa6b176d0
commit
a338c8db46
6 files changed
+174
-77
No files matched your search
@@ -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 = `<p class="myapps-empty" role="status">${i18n('app_browser_unavailable')}</p>`;
|
||||
});
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) => `<a href="${all_variables[key]}" target="_blank">${text}</a>`);
|
||||
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, '<br />');
|
||||
}
|
||||
// replace %% occurrences with the values in replacements
|
||||
// %% is for simple text replacements
|
||||
// %strong% is for <strong> tags
|
||||
// e.g. "Hello, %strong%" => "Hello, <strong>World</strong>"
|
||||
// e.g. "Hello, %%" => "Hello, World"
|
||||
// e.g. "Hello, %strong%, %%!" => "Hello, <strong>World</strong>, 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%', `<strong>${ replacements[i] }</strong>`);
|
||||
} else if ( index < index2 ) {
|
||||
str = str.replace('%strong%', `<strong>${ replacements[i] }</strong>`);
|
||||
} else {
|
||||
str = str.replace('%%', replacements[i]);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
export default {};
|
||||
export default {};
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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) => `<a href="${all_variables[key]}" target="_blank">${text}</a>`);
|
||||
str = str.replace(/{{(.*?)}}/g, (_, key) => all_variables[key]);
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs `window.i18n` over the given language table, which must include `en`.
|
||||
* @param {Record<string, { dictionary: Record<string, string> }>} 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, '<br />');
|
||||
}
|
||||
// replace %% occurrences with the values in replacements
|
||||
// %% is for simple text replacements
|
||||
// %strong% is for <strong> tags
|
||||
// e.g. "Hello, %strong%" => "Hello, <strong>World</strong>"
|
||||
// e.g. "Hello, %%" => "Hello, World"
|
||||
// e.g. "Hello, %strong%, %%!" => "Hello, <strong>World</strong>, 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%', `<strong>${ replacements[i] }</strong>`);
|
||||
} else if ( index < index2 ) {
|
||||
str = str.replace('%strong%', `<strong>${ replacements[i] }</strong>`);
|
||||
} else {
|
||||
str = str.replace('%%', replacements[i]);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
};
|
||||
}
|
||||
@@ -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(`<nav class="toolbar-row" aria-label="${i18n('toolbar_label')}">
|
||||
${shouldShowUpgrade(user) ? `<a class="button button-primary toolbar-upgrade" href="/dashboard?upgrade=1#usage" target="_blank" rel="noopener noreferrer">${i18n('toolbar_upgrade')}</a>` : ''}
|
||||
@@ -165,4 +166,7 @@ async function mount () {
|
||||
}
|
||||
} catch { /* An avatar is optional. */ }
|
||||
}
|
||||
mount().catch(() => $root.html(`<p class="toolbar-unavailable" role="status">${i18n('toolbar_unavailable')}</p>`));
|
||||
mount().catch(async () => {
|
||||
await localeReady;
|
||||
$root.html(`<p class="toolbar-unavailable" role="status">${i18n('toolbar_unavailable')}</p>`);
|
||||
});
|
||||
Reference in new issue
Block a user