// ***************************************************************************** // Copyright (C) 2021 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import * as path from 'path'; import * as fs from '@theia/core/shared/fs-extra'; 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, Path, URI } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { LanguagePackBundle, LanguagePackService } from '../../common/language-pack-service'; export interface VSCodeNlsConfig { locale: string availableLanguages: Record _languagePackSupport?: boolean _languagePackId?: string _translationsConfigFile?: string _cacheRoot?: string _corruptedFile?: string } @injectable() export class HostedPluginLocalizationService implements BackendApplicationContribution { @inject(LocalizationProvider) protected readonly localizationProvider: LocalizationProvider; @inject(LanguagePackService) protected readonly languagePackService: LanguagePackService; @inject(EnvVariablesServer) protected readonly envVariables: EnvVariablesServer; protected localizationDisposeMap = new Map(); protected translationConfigFiles: Map = new Map(); protected readonly _ready = new Deferred(); /** * This promise resolves when the cache has been cleaned up after starting the backend server. * Once resolved, the service allows to cache localization files for plugins. */ ready = this._ready.promise; initialize(): MaybePromise { this.getLocalizationCacheDir() .then(cacheDir => fs.emptyDir(cacheDir)) .then(() => this._ready.resolve()); } async deployLocalizations(plugin: DeployedPlugin): Promise { const disposable = new DisposableCollection(); 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.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 // These bundles are purely used for translating plugins // The branch above builds localizations for Theia's own strings disposable.push(await this.updateLanguagePackBundles(plugin)); } if (!disposable.disposed) { const versionedId = PluginIdentifiers.componentsToVersionedId(plugin.metadata.model); disposable.push(Disposable.create(() => { this.localizationDisposeMap.delete(versionedId); })); this.localizationDisposeMap.set(versionedId, disposable); } } undeployLocalizations(plugin: PluginIdentifiers.VersionedId): void { this.localizationDisposeMap.get(plugin)?.dispose(); } protected async updateLanguagePackBundles(plugin: DeployedPlugin): Promise { const disposable = new DisposableCollection(); 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) { 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 if (plugin.metadata.model.l10n) { const bundleDirectory = packageUri.resolve(plugin.metadata.model.l10n); const bundles = await loadPluginBundles(bundleDirectory); if (bundles) { for (const [locale, bundle] of Object.entries(bundles)) { this.languagePackService.storeBundle(pluginId, locale, bundle); } disposable.push(Disposable.create(() => { // Dispose all bundles contributed by the deleted plugin this.languagePackService.deleteBundle(pluginId); })); } } return disposable; } /** * Performs localization of the plugin model. Translates entries such as command names, view names and other items. * * Translatable items are indicated with a `%id%` value. * The `id` is the translation key that gets replaced with the localized value for the currently selected language. * * Returns a copy of the plugin argument and does not modify the argument. * This is done to preserve the original `%id%` values for subsequent invocations of this method. */ async localizePlugin(plugin: DeployedPlugin): Promise { const currentLanguage = this.localizationProvider.getCurrentLanguage(); const pluginPath = new URI(plugin.metadata.model.packageUri).path.fsPath(); const pluginId = plugin.metadata.model.id; try { 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); }) as DeployedPlugin; } catch (err) { console.error(`Failed to localize plugin '${pluginId}'.`, err); } return plugin; } getNlsConfig(): VSCodeNlsConfig { const locale = this.localizationProvider.getCurrentLanguage(); const configFile = this.translationConfigFiles.get(locale); if (locale === nls.defaultLocale || !configFile) { return { locale, availableLanguages: {} }; } const cache = path.dirname(configFile); return { locale, availableLanguages: { '*': locale }, _languagePackSupport: true, _cacheRoot: cache, _languagePackId: locale, _translationsConfigFile: configFile }; } async buildTranslationConfig(plugins: DeployedPlugin[]): Promise { await this.ready; const cacheDir = await this.getLocalizationCacheDir(); const configs = new Map>(); for (const plugin of plugins) { if (plugin.contributes?.localizations) { const pluginPath = new URI(plugin.metadata.model.packageUri).path.fsPath(); for (const localization of plugin.contributes.localizations) { const config = configs.get(localization.languageId) || {}; for (const translation of localization.translations) { const fullPath = path.join(pluginPath, translation.path); config[translation.id] = fullPath; } configs.set(localization.languageId, config); } } } for (const [language, config] of configs.entries()) { const languageConfigDir = path.join(cacheDir, language); await fs.mkdirs(languageConfigDir); const configFile = path.join(languageConfigDir, `nls.config.${language}.json`); this.translationConfigFiles.set(language, configFile); await fs.writeJson(configFile, config); } } protected async getLocalizationCacheDir(): Promise { const configDir = new URI(await this.envVariables.getConfigDirUri()).path.fsPath(); const cacheDir = path.join(configDir, 'localization-cache'); return cacheDir; } } // New plugin localization logic using vscode.l10n 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 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> { try { const directory = l10nUri.path.fsPath(); const files = await fs.readdir(directory); const result: Record = {}; await Promise.all(files.map(async fileName => { const match = fileName.match(/^bundle\.l10n\.([\w\-]+)\.json$/); if (match) { const locale = match[1]; const contents = await fs.readJSON(path.join(directory, fileName)); result[locale] = { contents, uri: l10nUri.resolve(fileName).toString() }; } })); return result; } catch (err) { // The directory either doesn't exist or its contents cannot be parsed console.error(`Failed to load plugin localization bundles from ${l10nUri}.`, err); // In any way we should just safely return undefined return undefined; } } type UnprocessedL10nBundle = Record; function processL10nBundle(bundle: UnprocessedL10nBundle): Record { const processedBundle: Record = {}; for (const [name, value] of Object.entries(bundle)) { const stringValue = typeof value === 'string' ? value : value.message; processedBundle[name] = stringValue; } return processedBundle; } // Old plugin localization logic for vscode-nls // vscode-nls was used until version 1.73 of VSCode to translate extensions // This style of localization is still used by vscode language packs function buildLocalizations(packageUri: string, localizations: PluginLocalization[]): LazyLocalization[] { const theiaLocalizations: LazyLocalization[] = []; const packagePath = new URI(packageUri).path; for (const localization of localizations) { let cachedLocalization: Promise> | undefined; const theiaLocalization: LazyLocalization = { languageId: localization.languageId, languageName: localization.languageName, localizedLanguageName: localization.localizedLanguageName, languagePack: true, async getTranslations(): Promise> { cachedLocalization ??= loadTranslations(packagePath, localization.translations); return cachedLocalization; }, }; 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); values[translationKey] = item; } } } catch (err) { console.error('Failed to load translation from: ' + translationPath, err); } return values; })); return Object.assign({}, ...allTranslations); } function buildTranslationKey(pluginId: string, scope: string, key: string): string { return `${pluginId}/${Localization.transformKey(scope)}/${key}`; } // Localization logic for `package.json` entries // Extensions can use `package.nls.json` files to store translations for values in their package.json // This logic has not changed with the introduction of the vscode.l10n API interface PackageTranslation { translation?: Record default?: Record } async function loadPackageTranslations(pluginPath: string, locale: string): Promise { const localizedPluginPath = path.join(pluginPath, `package.nls.${locale}.json`); try { const defaultValue = coerceLocalizations(await fs.readJson(path.join(pluginPath, 'package.nls.json'))); if (await fs.pathExists(localizedPluginPath)) { return { translation: coerceLocalizations(await fs.readJson(localizedPluginPath)), default: defaultValue }; } return { default: defaultValue }; } catch (e) { if (e.code !== 'ENOENT') { throw e; } return {}; } } interface LocalizeInfo { message: string comment?: string } function isLocalizeInfo(obj: unknown): obj is LocalizeInfo { return isObject(obj) && 'message' in obj || false; } function coerceLocalizations(translations: Record): Record { for (const [key, value] of Object.entries(translations)) { if (isLocalizeInfo(value)) { translations[key] = value.message; } else if (typeof value !== 'string') { // Only strings or LocalizeInfo values are valid translations[key] = 'INVALID TRANSLATION VALUE'; } } return translations as Record; } function localizePackage(value: unknown, translations: PackageTranslation, callback: (key: string, defaultValue: string) => string): unknown { if (typeof value === 'string') { let result = value; if (value.length > 2 && value.startsWith('%') && value.endsWith('%')) { const key = value.slice(1, -1); if (translations.translation && key in translations.translation) { result = translations.translation[key]; } else if (translations.default && key in translations.default) { result = callback(key, translations.default[key]); } } return result; } if (Array.isArray(value)) { const result = []; for (const item of value) { result.push(localizePackage(item, translations, callback)); } return result; } if (isObject(value)) { const result: Record = {}; for (const [name, item] of Object.entries(value)) { result[name] = localizePackage(item, translations, callback); } return result; } return value; }