diff --git a/packages/core/src/node/i18n/localization-contribution.ts b/packages/core/src/node/i18n/localization-contribution.ts index 2fd8815d04ce4..74b163809fb82 100644 --- a/packages/core/src/node/i18n/localization-contribution.ts +++ b/packages/core/src/node/i18n/localization-contribution.ts @@ -18,7 +18,7 @@ import * as fs from 'fs-extra'; import { inject, injectable, named } from 'inversify'; import { ContributionProvider, isObject } from '../../common'; import { LanguageInfo, Localization } from '../../common/i18n/localization'; -import { LocalizationProvider } from './localization-provider'; +import { LazyLocalization, LocalizationProvider } from './localization-provider'; export const LocalizationContribution = Symbol('LocalizationContribution'); @@ -41,38 +41,42 @@ export class LocalizationRegistry { )); } - registerLocalization(localization: Localization): void { + registerLocalization(localization: Localization | LazyLocalization): void { + if (!LazyLocalization.is(localization)) { + localization = LazyLocalization.fromLocalization(localization); + } this.localizationProvider.addLocalizations(localization); } registerLocalizationFromRequire(locale: string | LanguageInfo, required: unknown): void { const translations = this.flattenTranslations(required); - this.registerLocalization(this.createLocalization(locale, translations)); + this.registerLocalization(this.createLocalization(locale, () => Promise.resolve(translations))); } - async registerLocalizationFromFile(localizationPath: string, locale?: string | LanguageInfo): Promise { + registerLocalizationFromFile(localizationPath: string, locale?: string | LanguageInfo): void { if (!locale) { locale = this.identifyLocale(localizationPath); } if (!locale) { throw new Error('Could not determine locale from path.'); } - const translationJson = await fs.readJson(localizationPath); - const translations = this.flattenTranslations(translationJson); - this.registerLocalization(this.createLocalization(locale, translations)); + this.registerLocalization(this.createLocalization(locale, async () => { + const translationJson = await fs.readJson(localizationPath); + return this.flattenTranslations(translationJson); + })); } - protected createLocalization(locale: string | LanguageInfo, translations: Record): Localization { - let localization: Localization; + protected createLocalization(locale: string | LanguageInfo, translations: () => Promise>): LazyLocalization { + let localization: LazyLocalization; if (typeof locale === 'string') { localization = { languageId: locale, - translations + getTranslations: translations }; } else { localization = { ...locale, - translations + getTranslations: translations }; } return localization; diff --git a/packages/core/src/node/i18n/localization-provider.ts b/packages/core/src/node/i18n/localization-provider.ts index 0ceab71867c36..7064aaba3fbc1 100644 --- a/packages/core/src/node/i18n/localization-provider.ts +++ b/packages/core/src/node/i18n/localization-provider.ts @@ -17,24 +17,70 @@ import { injectable } from 'inversify'; import { nls } from '../../common/nls'; import { LanguageInfo, Localization } from '../../common/i18n/localization'; +import { Disposable } from '../../common/disposable'; +import { isObject } from '../../common/types'; + +/** + * Localization data structure that contributes its localizations asynchronously. + * Allows to load localizations on demand when requested by the user. + */ +export interface LazyLocalization extends LanguageInfo { + getTranslations(): Promise>; +} + +export namespace LazyLocalization { + export function is(obj: unknown): obj is LazyLocalization { + return isObject(obj) && typeof obj.languageId === 'string' && typeof obj.getTranslations === 'function'; + } + export function fromLocalization(localization: Localization): LazyLocalization { + const { + languageId, + languageName, + languagePack, + localizedLanguageName, + translations + } = localization; + return { + languageId, + languageName, + languagePack, + localizedLanguageName, + getTranslations: () => Promise.resolve(translations) + }; + } + export async function toLocalization(localization: LazyLocalization): Promise { + const { + languageId, + languageName, + languagePack, + localizedLanguageName + } = localization; + return { + languageId, + languageName, + languagePack, + localizedLanguageName, + translations: await localization.getTranslations() + }; + } +} @injectable() export class LocalizationProvider { - protected localizations: Localization[] = []; + protected localizations: LazyLocalization[] = []; protected currentLanguage = nls.defaultLocale; - addLocalizations(...localizations: Localization[]): void { + addLocalizations(...localizations: LazyLocalization[]): Disposable { this.localizations.push(...localizations); - } - - removeLocalizations(...localizations: Localization[]): void { - for (const localization of localizations) { - const index = this.localizations.indexOf(localization); - if (index >= 0) { - this.localizations.splice(index, 1); + return Disposable.create(() => { + for (const localization of localizations) { + const index = this.localizations.indexOf(localization); + if (index >= 0) { + this.localizations.splice(index, 1); + } } - } + }); } setCurrentLanguage(languageId: string): void { @@ -61,12 +107,13 @@ export class LocalizationProvider { return Array.from(languageInfos.values()).sort((a, b) => a.languageId.localeCompare(b.languageId)); } - loadLocalization(languageId: string): Localization { + async loadLocalization(languageId: string): Promise { const merged: Localization = { languageId, translations: {} }; - for (const localization of this.localizations.filter(e => e.languageId === languageId)) { + const localizations = await Promise.all(this.localizations.filter(e => e.languageId === languageId).map(LazyLocalization.toLocalization)); + for (const localization of localizations) { merged.languageName ||= localization.languageName; merged.localizedLanguageName ||= localization.localizedLanguageName; merged.languagePack ||= localization.languagePack; diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 62c73cc56451b..88a73813995af 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -643,8 +643,7 @@ export interface Localization { export interface Translation { id: string; path: string; - version: string; - contents: { [scope: string]: { [key: string]: string } } + cachedContents?: { [scope: string]: { [key: string]: string } }; } export interface SnippetContribution { diff --git a/packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts b/packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts index 91e17f7bba3de..98e59e637cf26 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts @@ -16,13 +16,13 @@ import * as path from 'path'; import * as fs from '@theia/core/shared/fs-extra'; -import { LocalizationProvider } from '@theia/core/lib/node/i18n/localization-provider'; +import { LazyLocalization, LocalizationProvider } from '@theia/core/lib/node/i18n/localization-provider'; import { Localization } from '@theia/core/lib/common/i18n/localization'; import { inject, injectable } from '@theia/core/shared/inversify'; import { DeployedPlugin, Localization as PluginLocalization, PluginIdentifiers, Translation } from '../../common'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { BackendApplicationContribution } from '@theia/core/lib/node'; -import { Disposable, DisposableCollection, isObject, MaybePromise, nls, URI } from '@theia/core'; +import { Disposable, DisposableCollection, isObject, MaybePromise, nls, Path, URI } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { LanguagePackBundle, LanguagePackService } from '../../common/language-pack-service'; @@ -70,11 +70,8 @@ export class HostedPluginLocalizationService implements BackendApplicationContri if (plugin.contributes?.localizations) { // Indicator that this plugin is a vscode language pack // Language packs translate Theia and some builtin vscode extensions - const localizations = buildLocalizations(plugin.contributes.localizations); - disposable.push(Disposable.create(() => { - this.localizationProvider.removeLocalizations(...localizations); - })); - this.localizationProvider.addLocalizations(...localizations); + const localizations = buildLocalizations(plugin.metadata.model.packageUri, plugin.contributes.localizations); + disposable.push(this.localizationProvider.addLocalizations(...localizations)); } if (plugin.metadata.model.l10n || plugin.contributes?.localizations) { // Indicator that this plugin is a vscode language pack or has its own localization bundles @@ -100,26 +97,29 @@ export class HostedPluginLocalizationService implements BackendApplicationContri const pluginId = plugin.metadata.model.id; const packageUri = new URI(plugin.metadata.model.packageUri); if (plugin.contributes?.localizations) { + const l10nPromises: Promise[] = []; for (const localization of plugin.contributes.localizations) { for (const translation of localization.translations) { - const l10n = getL10nTranslation(translation); - if (l10n) { - const translatedPluginId = translation.id; - const translationUri = packageUri.resolve(translation.path); - const locale = localization.languageId; - // We store a bundle for another extension in here - // Hence we use `translatedPluginId` instead of `pluginId` - this.languagePackService.storeBundle(translatedPluginId, locale, { - contents: processL10nBundle(l10n), - uri: translationUri.toString() - }); - disposable.push(Disposable.create(() => { - // Only dispose the deleted locale for the specific plugin - this.languagePackService.deleteBundle(translatedPluginId, locale); - })); - } + l10nPromises.push(getL10nTranslation(plugin.metadata.model.packageUri, translation).then(l10n => { + if (l10n) { + const translatedPluginId = translation.id; + const translationUri = packageUri.resolve(translation.path); + const locale = localization.languageId; + // We store a bundle for another extension in here + // Hence we use `translatedPluginId` instead of `pluginId` + this.languagePackService.storeBundle(translatedPluginId, locale, { + contents: processL10nBundle(l10n), + uri: translationUri.toString() + }); + disposable.push(Disposable.create(() => { + // Only dispose the deleted locale for the specific plugin + this.languagePackService.deleteBundle(translatedPluginId, locale); + })); + } + })); } } + await Promise.all(l10nPromises); } // The `l10n` field of the plugin model points to a relative directory path within the plugin // 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 */ async localizePlugin(plugin: DeployedPlugin): Promise { const currentLanguage = this.localizationProvider.getCurrentLanguage(); - const localization = this.localizationProvider.loadLocalization(currentLanguage); const pluginPath = new URI(plugin.metadata.model.packageUri).path.fsPath(); const pluginId = plugin.metadata.model.id; try { - const translations = await loadPackageTranslations(pluginPath, currentLanguage); + const [localization, translations] = await Promise.all([ + this.localizationProvider.loadLocalization(currentLanguage), + loadPackageTranslations(pluginPath, currentLanguage), + ]); plugin = localizePackage(plugin, translations, (key, original) => { const fullKey = `${pluginId}/package/${key}`; return Localization.localize(localization, fullKey, original); @@ -218,10 +220,24 @@ export class HostedPluginLocalizationService implements BackendApplicationContri // New plugin localization logic using vscode.l10n -function getL10nTranslation(translation: Translation): UnprocessedL10nBundle | undefined { +async function getL10nTranslation(packageUri: string, translation: Translation): Promise { // 'bundle' is a special key that contains all translations for the l10n vscode API // If that doesn't exist, we can assume that the language pack is using the old vscode-nls API - return translation.contents.bundle; + if (translation.cachedContents) { + return translation.cachedContents.bundle; + } else { + const translationPath = new URI(packageUri).path.join(translation.path).fsPath(); + try { + const translationJson = await fs.readJson(translationPath); + translation.cachedContents = translationJson?.contents; + return translationJson?.contents?.bundle; + } catch (err) { + console.error('Failed reading translation file from: ' + translationPath, err); + // Store an empty object, so we don't reattempt to load the file + translation.cachedContents = {}; + return undefined; + } + } } async function loadPluginBundles(l10nUri: URI): Promise | undefined> { @@ -262,28 +278,47 @@ function processL10nBundle(bundle: UnprocessedL10nBundle): Record> | undefined; + const theiaLocalization: LazyLocalization = { languageId: localization.languageId, languageName: localization.languageName, localizedLanguageName: localization.localizedLanguageName, languagePack: true, - translations: {} + async getTranslations(): Promise> { + cachedLocalization ??= loadTranslations(packagePath, localization.translations); + return cachedLocalization; + }, }; - for (const translation of localization.translations) { - for (const [scope, value] of Object.entries(translation.contents)) { + theiaLocalizations.push(theiaLocalization); + } + return theiaLocalizations; +} + +async function loadTranslations(packagePath: Path, translations: Translation[]): Promise> { + const allTranslations = await Promise.all(translations.map(async translation => { + const values: Record = {}; + const translationPath = packagePath.join(translation.path).fsPath(); + try { + const translationJson = await fs.readJson(translationPath); + const translationContents: Record> = translationJson?.contents; + for (const [scope, value] of Object.entries(translationContents ?? {})) { for (const [key, item] of Object.entries(value)) { const translationKey = buildTranslationKey(translation.id, scope, key); - theiaLocalization.translations[translationKey] = item; + values[translationKey] = item; } } + } catch (err) { + console.error('Failed to load translation from: ' + translationPath, err); } - theiaLocalizations.push(theiaLocalization); - } - return theiaLocalizations; + return values; + })); + return Object.assign({}, ...allTranslations); } function buildTranslationKey(pluginId: string, scope: string, key: string): string { diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 657b0924424fd..084356e465de4 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -364,18 +364,17 @@ export class TheiaPluginScanner implements PluginScanner { console.error(`Could not read '${rawPlugin.name}' contribution 'terminals'.`, rawPlugin.contributes.terminal, err); } - const [localizationsResult, languagesResult, grammarsResult] = await Promise.allSettled([ - this.readLocalizations(rawPlugin), + try { + contributions.localizations = this.readLocalizations(rawPlugin); + } catch (err) { + console.error(`Could not read '${rawPlugin.name}' contribution 'localizations'.`, rawPlugin.contributes.localizations, err); + } + + const [languagesResult, grammarsResult] = await Promise.allSettled([ rawPlugin.contributes.languages ? this.readLanguages(rawPlugin.contributes.languages, rawPlugin.packagePath) : undefined, rawPlugin.contributes.grammars ? this.grammarsReader.readGrammars(rawPlugin.contributes.grammars, rawPlugin.packagePath) : undefined ]); - if (localizationsResult.status === 'fulfilled') { - contributions.localizations = localizationsResult.value; - } else { - console.error(`Could not read '${rawPlugin.name}' contribution 'localizations'.`, rawPlugin.contributes.localizations, localizationsResult.reason); - } - if (rawPlugin.contributes.languages) { if (languagesResult.status === 'fulfilled') { contributions.languages = languagesResult.value; @@ -402,31 +401,29 @@ export class TheiaPluginScanner implements PluginScanner { return pck.contributes.terminal.profiles.filter(profile => profile.id && profile.title); } - protected async readLocalizations(pck: PluginPackage): Promise { + protected readLocalizations(pck: PluginPackage): Localization[] | undefined { if (!pck.contributes || !pck.contributes.localizations) { return undefined; } - return Promise.all(pck.contributes.localizations.map(e => this.readLocalization(e, pck.packagePath))); + return pck.contributes.localizations.map(e => this.readLocalization(e, pck.packagePath)); } - protected async readLocalization({ languageId, languageName, localizedLanguageName, translations }: PluginPackageLocalization, pluginPath: string): Promise { + protected readLocalization({ languageId, languageName, localizedLanguageName, translations }: PluginPackageLocalization, pluginPath: string): Localization { const local: Localization = { languageId, languageName, localizedLanguageName, translations: [] }; - local.translations = await Promise.all(translations.map(e => this.readTranslation(e, pluginPath))); + local.translations = translations.map(e => this.readTranslation(e, pluginPath)); return local; } - protected async readTranslation(packageTranslation: PluginPackageTranslation, pluginPath: string): Promise { - const translation = await this.readJson(path.resolve(pluginPath, packageTranslation.path)); - if (!translation) { - throw new Error(`Could not read json file '${packageTranslation.path}'.`); - } - translation.id = packageTranslation.id; - translation.path = packageTranslation.path; + protected readTranslation(packageTranslation: PluginPackageTranslation, pluginPath: string): Translation { + const translation: Translation = { + id: packageTranslation.id, + path: packageTranslation.path + }; return translation; }