Skip to content

Commit c745963

Browse files
authored
Load localizations lazily (#12932)
1 parent d2a2bb6 commit c745963

File tree

5 files changed

+163
-81
lines changed

5 files changed

+163
-81
lines changed

packages/core/src/node/i18n/localization-contribution.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import * as fs from 'fs-extra';
1818
import { inject, injectable, named } from 'inversify';
1919
import { ContributionProvider, isObject } from '../../common';
2020
import { LanguageInfo, Localization } from '../../common/i18n/localization';
21-
import { LocalizationProvider } from './localization-provider';
21+
import { LazyLocalization, LocalizationProvider } from './localization-provider';
2222

2323
export const LocalizationContribution = Symbol('LocalizationContribution');
2424

@@ -41,38 +41,42 @@ export class LocalizationRegistry {
4141
));
4242
}
4343

44-
registerLocalization(localization: Localization): void {
44+
registerLocalization(localization: Localization | LazyLocalization): void {
45+
if (!LazyLocalization.is(localization)) {
46+
localization = LazyLocalization.fromLocalization(localization);
47+
}
4548
this.localizationProvider.addLocalizations(localization);
4649
}
4750

4851
registerLocalizationFromRequire(locale: string | LanguageInfo, required: unknown): void {
4952
const translations = this.flattenTranslations(required);
50-
this.registerLocalization(this.createLocalization(locale, translations));
53+
this.registerLocalization(this.createLocalization(locale, () => Promise.resolve(translations)));
5154
}
5255

53-
async registerLocalizationFromFile(localizationPath: string, locale?: string | LanguageInfo): Promise<void> {
56+
registerLocalizationFromFile(localizationPath: string, locale?: string | LanguageInfo): void {
5457
if (!locale) {
5558
locale = this.identifyLocale(localizationPath);
5659
}
5760
if (!locale) {
5861
throw new Error('Could not determine locale from path.');
5962
}
60-
const translationJson = await fs.readJson(localizationPath);
61-
const translations = this.flattenTranslations(translationJson);
62-
this.registerLocalization(this.createLocalization(locale, translations));
63+
this.registerLocalization(this.createLocalization(locale, async () => {
64+
const translationJson = await fs.readJson(localizationPath);
65+
return this.flattenTranslations(translationJson);
66+
}));
6367
}
6468

65-
protected createLocalization(locale: string | LanguageInfo, translations: Record<string, string>): Localization {
66-
let localization: Localization;
69+
protected createLocalization(locale: string | LanguageInfo, translations: () => Promise<Record<string, string>>): LazyLocalization {
70+
let localization: LazyLocalization;
6771
if (typeof locale === 'string') {
6872
localization = {
6973
languageId: locale,
70-
translations
74+
getTranslations: translations
7175
};
7276
} else {
7377
localization = {
7478
...locale,
75-
translations
79+
getTranslations: translations
7680
};
7781
}
7882
return localization;

packages/core/src/node/i18n/localization-provider.ts

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,70 @@
1717
import { injectable } from 'inversify';
1818
import { nls } from '../../common/nls';
1919
import { LanguageInfo, Localization } from '../../common/i18n/localization';
20+
import { Disposable } from '../../common/disposable';
21+
import { isObject } from '../../common/types';
22+
23+
/**
24+
* Localization data structure that contributes its localizations asynchronously.
25+
* Allows to load localizations on demand when requested by the user.
26+
*/
27+
export interface LazyLocalization extends LanguageInfo {
28+
getTranslations(): Promise<Record<string, string>>;
29+
}
30+
31+
export namespace LazyLocalization {
32+
export function is(obj: unknown): obj is LazyLocalization {
33+
return isObject<LazyLocalization>(obj) && typeof obj.languageId === 'string' && typeof obj.getTranslations === 'function';
34+
}
35+
export function fromLocalization(localization: Localization): LazyLocalization {
36+
const {
37+
languageId,
38+
languageName,
39+
languagePack,
40+
localizedLanguageName,
41+
translations
42+
} = localization;
43+
return {
44+
languageId,
45+
languageName,
46+
languagePack,
47+
localizedLanguageName,
48+
getTranslations: () => Promise.resolve(translations)
49+
};
50+
}
51+
export async function toLocalization(localization: LazyLocalization): Promise<Localization> {
52+
const {
53+
languageId,
54+
languageName,
55+
languagePack,
56+
localizedLanguageName
57+
} = localization;
58+
return {
59+
languageId,
60+
languageName,
61+
languagePack,
62+
localizedLanguageName,
63+
translations: await localization.getTranslations()
64+
};
65+
}
66+
}
2067

2168
@injectable()
2269
export class LocalizationProvider {
2370

24-
protected localizations: Localization[] = [];
71+
protected localizations: LazyLocalization[] = [];
2572
protected currentLanguage = nls.defaultLocale;
2673

27-
addLocalizations(...localizations: Localization[]): void {
74+
addLocalizations(...localizations: LazyLocalization[]): Disposable {
2875
this.localizations.push(...localizations);
29-
}
30-
31-
removeLocalizations(...localizations: Localization[]): void {
32-
for (const localization of localizations) {
33-
const index = this.localizations.indexOf(localization);
34-
if (index >= 0) {
35-
this.localizations.splice(index, 1);
76+
return Disposable.create(() => {
77+
for (const localization of localizations) {
78+
const index = this.localizations.indexOf(localization);
79+
if (index >= 0) {
80+
this.localizations.splice(index, 1);
81+
}
3682
}
37-
}
83+
});
3884
}
3985

4086
setCurrentLanguage(languageId: string): void {
@@ -61,12 +107,13 @@ export class LocalizationProvider {
61107
return Array.from(languageInfos.values()).sort((a, b) => a.languageId.localeCompare(b.languageId));
62108
}
63109

64-
loadLocalization(languageId: string): Localization {
110+
async loadLocalization(languageId: string): Promise<Localization> {
65111
const merged: Localization = {
66112
languageId,
67113
translations: {}
68114
};
69-
for (const localization of this.localizations.filter(e => e.languageId === languageId)) {
115+
const localizations = await Promise.all(this.localizations.filter(e => e.languageId === languageId).map(LazyLocalization.toLocalization));
116+
for (const localization of localizations) {
70117
merged.languageName ||= localization.languageName;
71118
merged.localizedLanguageName ||= localization.localizedLanguageName;
72119
merged.languagePack ||= localization.languagePack;

packages/plugin-ext/src/common/plugin-protocol.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -643,8 +643,7 @@ export interface Localization {
643643
export interface Translation {
644644
id: string;
645645
path: string;
646-
version: string;
647-
contents: { [scope: string]: { [key: string]: string } }
646+
cachedContents?: { [scope: string]: { [key: string]: string } };
648647
}
649648

650649
export interface SnippetContribution {

packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts

Lines changed: 72 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616

1717
import * as path from 'path';
1818
import * as fs from '@theia/core/shared/fs-extra';
19-
import { LocalizationProvider } from '@theia/core/lib/node/i18n/localization-provider';
19+
import { LazyLocalization, LocalizationProvider } from '@theia/core/lib/node/i18n/localization-provider';
2020
import { Localization } from '@theia/core/lib/common/i18n/localization';
2121
import { inject, injectable } from '@theia/core/shared/inversify';
2222
import { DeployedPlugin, Localization as PluginLocalization, PluginIdentifiers, Translation } from '../../common';
2323
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
2424
import { BackendApplicationContribution } from '@theia/core/lib/node';
25-
import { Disposable, DisposableCollection, isObject, MaybePromise, nls, URI } from '@theia/core';
25+
import { Disposable, DisposableCollection, isObject, MaybePromise, nls, Path, URI } from '@theia/core';
2626
import { Deferred } from '@theia/core/lib/common/promise-util';
2727
import { LanguagePackBundle, LanguagePackService } from '../../common/language-pack-service';
2828

@@ -70,11 +70,8 @@ export class HostedPluginLocalizationService implements BackendApplicationContri
7070
if (plugin.contributes?.localizations) {
7171
// Indicator that this plugin is a vscode language pack
7272
// Language packs translate Theia and some builtin vscode extensions
73-
const localizations = buildLocalizations(plugin.contributes.localizations);
74-
disposable.push(Disposable.create(() => {
75-
this.localizationProvider.removeLocalizations(...localizations);
76-
}));
77-
this.localizationProvider.addLocalizations(...localizations);
73+
const localizations = buildLocalizations(plugin.metadata.model.packageUri, plugin.contributes.localizations);
74+
disposable.push(this.localizationProvider.addLocalizations(...localizations));
7875
}
7976
if (plugin.metadata.model.l10n || plugin.contributes?.localizations) {
8077
// Indicator that this plugin is a vscode language pack or has its own localization bundles
@@ -100,26 +97,29 @@ export class HostedPluginLocalizationService implements BackendApplicationContri
10097
const pluginId = plugin.metadata.model.id;
10198
const packageUri = new URI(plugin.metadata.model.packageUri);
10299
if (plugin.contributes?.localizations) {
100+
const l10nPromises: Promise<void>[] = [];
103101
for (const localization of plugin.contributes.localizations) {
104102
for (const translation of localization.translations) {
105-
const l10n = getL10nTranslation(translation);
106-
if (l10n) {
107-
const translatedPluginId = translation.id;
108-
const translationUri = packageUri.resolve(translation.path);
109-
const locale = localization.languageId;
110-
// We store a bundle for another extension in here
111-
// Hence we use `translatedPluginId` instead of `pluginId`
112-
this.languagePackService.storeBundle(translatedPluginId, locale, {
113-
contents: processL10nBundle(l10n),
114-
uri: translationUri.toString()
115-
});
116-
disposable.push(Disposable.create(() => {
117-
// Only dispose the deleted locale for the specific plugin
118-
this.languagePackService.deleteBundle(translatedPluginId, locale);
119-
}));
120-
}
103+
l10nPromises.push(getL10nTranslation(plugin.metadata.model.packageUri, translation).then(l10n => {
104+
if (l10n) {
105+
const translatedPluginId = translation.id;
106+
const translationUri = packageUri.resolve(translation.path);
107+
const locale = localization.languageId;
108+
// We store a bundle for another extension in here
109+
// Hence we use `translatedPluginId` instead of `pluginId`
110+
this.languagePackService.storeBundle(translatedPluginId, locale, {
111+
contents: processL10nBundle(l10n),
112+
uri: translationUri.toString()
113+
});
114+
disposable.push(Disposable.create(() => {
115+
// Only dispose the deleted locale for the specific plugin
116+
this.languagePackService.deleteBundle(translatedPluginId, locale);
117+
}));
118+
}
119+
}));
121120
}
122121
}
122+
await Promise.all(l10nPromises);
123123
}
124124
// The `l10n` field of the plugin model points to a relative directory path within the plugin
125125
// It is supposed to contain localization bundles that contain translations of the plugin strings into different languages
@@ -150,11 +150,13 @@ export class HostedPluginLocalizationService implements BackendApplicationContri
150150
*/
151151
async localizePlugin(plugin: DeployedPlugin): Promise<DeployedPlugin> {
152152
const currentLanguage = this.localizationProvider.getCurrentLanguage();
153-
const localization = this.localizationProvider.loadLocalization(currentLanguage);
154153
const pluginPath = new URI(plugin.metadata.model.packageUri).path.fsPath();
155154
const pluginId = plugin.metadata.model.id;
156155
try {
157-
const translations = await loadPackageTranslations(pluginPath, currentLanguage);
156+
const [localization, translations] = await Promise.all([
157+
this.localizationProvider.loadLocalization(currentLanguage),
158+
loadPackageTranslations(pluginPath, currentLanguage),
159+
]);
158160
plugin = localizePackage(plugin, translations, (key, original) => {
159161
const fullKey = `${pluginId}/package/${key}`;
160162
return Localization.localize(localization, fullKey, original);
@@ -218,10 +220,24 @@ export class HostedPluginLocalizationService implements BackendApplicationContri
218220

219221
// New plugin localization logic using vscode.l10n
220222

221-
function getL10nTranslation(translation: Translation): UnprocessedL10nBundle | undefined {
223+
async function getL10nTranslation(packageUri: string, translation: Translation): Promise<UnprocessedL10nBundle | undefined> {
222224
// 'bundle' is a special key that contains all translations for the l10n vscode API
223225
// If that doesn't exist, we can assume that the language pack is using the old vscode-nls API
224-
return translation.contents.bundle;
226+
if (translation.cachedContents) {
227+
return translation.cachedContents.bundle;
228+
} else {
229+
const translationPath = new URI(packageUri).path.join(translation.path).fsPath();
230+
try {
231+
const translationJson = await fs.readJson(translationPath);
232+
translation.cachedContents = translationJson?.contents;
233+
return translationJson?.contents?.bundle;
234+
} catch (err) {
235+
console.error('Failed reading translation file from: ' + translationPath, err);
236+
// Store an empty object, so we don't reattempt to load the file
237+
translation.cachedContents = {};
238+
return undefined;
239+
}
240+
}
225241
}
226242

227243
async function loadPluginBundles(l10nUri: URI): Promise<Record<string, LanguagePackBundle> | undefined> {
@@ -262,28 +278,47 @@ function processL10nBundle(bundle: UnprocessedL10nBundle): Record<string, string
262278

263279
// Old plugin localization logic for vscode-nls
264280
// vscode-nls was used until version 1.73 of VSCode to translate extensions
281+
// This style of localization is still used by vscode language packs
265282

266-
function buildLocalizations(localizations: PluginLocalization[]): Localization[] {
267-
const theiaLocalizations: Localization[] = [];
283+
function buildLocalizations(packageUri: string, localizations: PluginLocalization[]): LazyLocalization[] {
284+
const theiaLocalizations: LazyLocalization[] = [];
285+
const packagePath = new URI(packageUri).path;
268286
for (const localization of localizations) {
269-
const theiaLocalization: Localization = {
287+
let cachedLocalization: Promise<Record<string, string>> | undefined;
288+
const theiaLocalization: LazyLocalization = {
270289
languageId: localization.languageId,
271290
languageName: localization.languageName,
272291
localizedLanguageName: localization.localizedLanguageName,
273292
languagePack: true,
274-
translations: {}
293+
async getTranslations(): Promise<Record<string, string>> {
294+
cachedLocalization ??= loadTranslations(packagePath, localization.translations);
295+
return cachedLocalization;
296+
},
275297
};
276-
for (const translation of localization.translations) {
277-
for (const [scope, value] of Object.entries(translation.contents)) {
298+
theiaLocalizations.push(theiaLocalization);
299+
}
300+
return theiaLocalizations;
301+
}
302+
303+
async function loadTranslations(packagePath: Path, translations: Translation[]): Promise<Record<string, string>> {
304+
const allTranslations = await Promise.all(translations.map(async translation => {
305+
const values: Record<string, string> = {};
306+
const translationPath = packagePath.join(translation.path).fsPath();
307+
try {
308+
const translationJson = await fs.readJson(translationPath);
309+
const translationContents: Record<string, Record<string, string>> = translationJson?.contents;
310+
for (const [scope, value] of Object.entries(translationContents ?? {})) {
278311
for (const [key, item] of Object.entries(value)) {
279312
const translationKey = buildTranslationKey(translation.id, scope, key);
280-
theiaLocalization.translations[translationKey] = item;
313+
values[translationKey] = item;
281314
}
282315
}
316+
} catch (err) {
317+
console.error('Failed to load translation from: ' + translationPath, err);
283318
}
284-
theiaLocalizations.push(theiaLocalization);
285-
}
286-
return theiaLocalizations;
319+
return values;
320+
}));
321+
return Object.assign({}, ...allTranslations);
287322
}
288323

289324
function buildTranslationKey(pluginId: string, scope: string, key: string): string {

0 commit comments

Comments
 (0)