From 3c90c83a0ce501854f064b79eb1d336f7eea8bd1 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt Date: Tue, 23 Aug 2022 10:16:01 -0700 Subject: [PATCH] use common extension manifest string replacer for web and desktop (#158834) * use common extension manifest string replacer for web and desktop * add tests and update type accordingly * fix build --- .../common/extensionNls.ts | 83 ++++++++++--- .../common/extensionsScannerService.ts | 71 +---------- .../test/common/extensionNls.test.ts | 110 ++++++++++++++++++ .../platform/extensions/common/extensions.ts | 5 +- .../extensions/browser/extensionEditor.ts | 2 +- 5 files changed, 186 insertions(+), 85 deletions(-) create mode 100644 src/vs/platform/extensionManagement/test/common/extensionNls.test.ts diff --git a/src/vs/platform/extensionManagement/common/extensionNls.ts b/src/vs/platform/extensionManagement/common/extensionNls.ts index 9bfa4a87a807b..52662603e10c2 100644 --- a/src/vs/platform/extensionManagement/common/extensionNls.ts +++ b/src/vs/platform/extensionManagement/common/extensionNls.ts @@ -3,30 +3,81 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { cloneAndChange } from 'vs/base/common/objects'; +import { isObject, isString } from 'vs/base/common/types'; +import { ILocalizedString } from 'vs/platform/action/common/action'; import { IExtensionManifest } from 'vs/platform/extensions/common/extensions'; - -const nlsRegex = /^%([\w\d.-]+)%$/i; +import { localize } from 'vs/nls'; export interface ITranslations { [key: string]: string | { message: string; comment: string[] }; } -export function localizeManifest(manifest: IExtensionManifest, translations: ITranslations, fallbackTranslations?: ITranslations): IExtensionManifest { - const patcher = (value: string): string | undefined => { - if (typeof value !== 'string') { - return undefined; - } - - const match = nlsRegex.exec(value); +export function localizeManifest(extensionManifest: IExtensionManifest, translations: ITranslations, fallbackTranslations?: ITranslations): IExtensionManifest { + try { + replaceNLStrings(extensionManifest, translations, fallbackTranslations); + } catch (error) { + /*Ignore Error*/ + } + return extensionManifest; +} - if (!match) { - return undefined; +/** + * This routine makes the following assumptions: + * The root element is an object literal + */ +function replaceNLStrings(extensionManifest: IExtensionManifest, messages: ITranslations, originalMessages?: ITranslations): void { + const processEntry = (obj: any, key: string | number, command?: boolean) => { + const value = obj[key]; + if (isString(value)) { + const str = value; + const length = str.length; + if (length > 1 && str[0] === '%' && str[length - 1] === '%') { + const messageKey = str.substr(1, length - 2); + let translated = messages[messageKey]; + // If the messages come from a language pack they might miss some keys + // Fill them from the original messages. + if (translated === undefined && originalMessages) { + translated = originalMessages[messageKey]; + } + const message: string | undefined = typeof translated === 'string' ? translated : translated.message; + if (message !== undefined) { + // This branch returns ILocalizedString's instead of Strings so that the Command Palette can contain both the localized and the original value. + const original = originalMessages?.[messageKey]; + const originalMessage: string | undefined = typeof original === 'string' ? original : original?.message; + if ( + // if we are translating the title or category of a command + command && (key === 'title' || key === 'category') && + // and the original value is not the same as the translated value + originalMessage && originalMessage !== message + ) { + const localizedString: ILocalizedString = { + value: message, + original: originalMessage + }; + obj[key] = localizedString; + } else { + obj[key] = message; + } + } else { + console.warn(`[${extensionManifest.name}]: ${localize('missingNLSKey', "Couldn't find message for key {0}.", messageKey)}`); + } + } + } else if (isObject(value)) { + for (const k in value) { + if (value.hasOwnProperty(k)) { + k === 'commands' ? processEntry(value, k, true) : processEntry(value, k, command); + } + } + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + processEntry(value, i, command); + } } - - const translation = translations?.[match[1]] ?? fallbackTranslations?.[match[1]] ?? value; - return typeof translation === 'string' ? translation : (typeof translation.message === 'string' ? translation.message : value); }; - return cloneAndChange(manifest, patcher); + for (const key in extensionManifest) { + if (extensionManifest.hasOwnProperty(key)) { + processEntry(extensionManifest, key); + } + } } diff --git a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts index 4de8730750ae9..464cf2bb35a4c 100644 --- a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts @@ -18,7 +18,7 @@ import * as platform from 'vs/base/common/platform'; import { basename, isEqual, joinPath } from 'vs/base/common/resources'; import * as semver from 'vs/base/common/semver/semver'; import Severity from 'vs/base/common/severity'; -import { isEmptyObject, isObject, isString } from 'vs/base/common/types'; +import { isEmptyObject } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; @@ -35,7 +35,7 @@ import { revive } from 'vs/base/common/marshalling'; import { IExtensionsProfileScannerService, IScannedProfileExtension } from 'vs/platform/extensionManagement/common/extensionsProfileScannerService'; import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; -import { ILocalizedString } from 'vs/platform/action/common/action'; +import { localizeManifest } from 'vs/platform/extensionManagement/common/extensionNls'; export type IScannedExtensionManifest = IRelaxedExtensionManifest & { __metadata?: Metadata }; @@ -658,7 +658,7 @@ class ExtensionsScanner extends Disposable { return extensionManifest; } const localized = localizedMessages.values || Object.create(null); - this.replaceNLStrings(nlsConfiguration.pseudo, extensionManifest, localized, defaults, extensionLocation); + return localizeManifest(extensionManifest, localized, defaults); } catch (error) { /*Ignore Error*/ } @@ -734,18 +734,16 @@ class ExtensionsScanner extends Disposable { /** * Parses original message bundle, returns null if the original message bundle is null. */ - private async resolveOriginalMessageBundle(originalMessageBundle: URI | null, errors: ParseError[]): Promise<{ [key: string]: string } | null> { + private async resolveOriginalMessageBundle(originalMessageBundle: URI | null, errors: ParseError[]): Promise<{ [key: string]: string } | undefined> { if (originalMessageBundle) { try { const originalBundleContent = (await this.fileService.readFile(originalMessageBundle)).value.toString(); return parse(originalBundleContent, errors); } catch (error) { /* Ignore Error */ - return null; } - } else { - return null; } + return; } /** @@ -776,65 +774,6 @@ class ExtensionsScanner extends Disposable { }); } - /** - * This routine makes the following assumptions: - * The root element is an object literal - */ - private replaceNLStrings(pseudo: boolean, literal: T, messages: MessageBag, originalMessages: MessageBag | null, extensionLocation: URI): void { - const processEntry = (obj: any, key: string | number, command?: boolean) => { - const value = obj[key]; - if (isString(value)) { - const str = value; - const length = str.length; - if (length > 1 && str[0] === '%' && str[length - 1] === '%') { - const messageKey = str.substr(1, length - 2); - let translated = messages[messageKey]; - // If the messages come from a language pack they might miss some keys - // Fill them from the original messages. - if (translated === undefined && originalMessages) { - translated = originalMessages[messageKey]; - } - let message: string | undefined = typeof translated === 'string' ? translated : translated.message; - if (message !== undefined) { - if (pseudo) { - // FF3B and FF3D is the Unicode zenkaku representation for [ and ] - message = '\uFF3B' + message.replace(/[aouei]/g, '$&$&') + '\uFF3D'; - } - // This branch returns ILocalizedString's instead of Strings so that the Command Palette can contain both the localized and the original value. - if (command && originalMessages && (key === 'title' || key === 'category')) { - const originalMessage = originalMessages[messageKey]; - const localizedString: ILocalizedString = { - value: message, - original: typeof originalMessage === 'string' ? originalMessage : originalMessage?.message - }; - obj[key] = localizedString; - } else { - obj[key] = message; - } - } else { - this.logService.warn(this.formatMessage(extensionLocation, localize('missingNLSKey', "Couldn't find message for key {0}.", messageKey))); - } - } - } else if (isObject(value)) { - for (const k in value) { - if (value.hasOwnProperty(k)) { - k === 'commands' ? processEntry(value, k, true) : processEntry(value, k, command); - } - } - } else if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - processEntry(value, i, command); - } - } - }; - - for (const key in literal) { - if (literal.hasOwnProperty(key)) { - processEntry(literal, key); - } - } - } - private formatMessage(extensionLocation: URI, message: string): string { return `[${extensionLocation.path}]: ${message}`; } diff --git a/src/vs/platform/extensionManagement/test/common/extensionNls.test.ts b/src/vs/platform/extensionManagement/test/common/extensionNls.test.ts new file mode 100644 index 0000000000000..0ad4f3b66f670 --- /dev/null +++ b/src/vs/platform/extensionManagement/test/common/extensionNls.test.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { deepClone } from 'vs/base/common/objects'; +import { ILocalizedString } from 'vs/platform/action/common/action'; +import { localizeManifest } from 'vs/platform/extensionManagement/common/extensionNls'; +import { IExtensionManifest, IConfiguration } from 'vs/platform/extensions/common/extensions'; + +const manifest: IExtensionManifest = { + name: 'test', + publisher: 'test', + version: '1.0.0', + engines: { + vscode: '*' + }, + contributes: { + commands: [ + { + command: 'test.command', + title: '%test.command.title%', + category: '%test.command.category%' + }, + ], + authentication: [ + { + id: 'test.authentication', + label: '%test.authentication.label%', + } + ], + configuration: { + // to ensure we test another "title" property + title: '%test.configuration.title%', + properties: { + 'test.configuration': { + type: 'string', + description: 'not important', + } + } + } + } +}; + +suite('Localize Manifest', () => { + test('replaces template strings', function () { + const localizedManifest = localizeManifest( + deepClone(manifest), + { + 'test.command.title': 'Test Command', + 'test.command.category': 'Test Category', + 'test.authentication.label': 'Test Authentication', + 'test.configuration.title': 'Test Configuration', + } + ); + + assert.strictEqual(localizedManifest.contributes?.commands?.[0].title, 'Test Command'); + assert.strictEqual(localizedManifest.contributes?.commands?.[0].category, 'Test Category'); + assert.strictEqual(localizedManifest.contributes?.authentication?.[0].label, 'Test Authentication'); + assert.strictEqual((localizedManifest.contributes?.configuration as IConfiguration).title, 'Test Configuration'); + }); + + test('replaces template strings with fallback if not found in translations', function () { + const localizedManifest = localizeManifest( + deepClone(manifest), + {}, + { + 'test.command.title': 'Test Command', + 'test.command.category': 'Test Category', + 'test.authentication.label': 'Test Authentication', + 'test.configuration.title': 'Test Configuration', + } + ); + + assert.strictEqual(localizedManifest.contributes?.commands?.[0].title, 'Test Command'); + assert.strictEqual(localizedManifest.contributes?.commands?.[0].category, 'Test Category'); + assert.strictEqual(localizedManifest.contributes?.authentication?.[0].label, 'Test Authentication'); + assert.strictEqual((localizedManifest.contributes?.configuration as IConfiguration).title, 'Test Configuration'); + }); + + test('replaces template strings - command title & categories become ILocalizedString', function () { + const localizedManifest = localizeManifest( + deepClone(manifest), + { + 'test.command.title': 'Befehl test', + 'test.command.category': 'Testkategorie', + 'test.authentication.label': 'Testauthentifizierung', + 'test.configuration.title': 'Testkonfiguration', + }, + { + 'test.command.title': 'Test Command', + 'test.command.category': 'Test Category', + 'test.authentication.label': 'Test Authentication', + 'test.configuration.title': 'Test Configuration', + } + ); + + const title = localizedManifest.contributes?.commands?.[0].title as ILocalizedString; + const category = localizedManifest.contributes?.commands?.[0].category as ILocalizedString; + assert.strictEqual(title.value, 'Befehl test'); + assert.strictEqual(title.original, 'Test Command'); + assert.strictEqual(category.value, 'Testkategorie'); + assert.strictEqual(category.original, 'Test Category'); + + // Everything else stays as a string. + assert.strictEqual(localizedManifest.contributes?.authentication?.[0].label, 'Testauthentifizierung'); + assert.strictEqual((localizedManifest.contributes?.configuration as IConfiguration).title, 'Testkonfiguration'); + }); +}); diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index 8ae2e29498276..9296a375d9c37 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -6,6 +6,7 @@ import Severity from 'vs/base/common/severity'; import * as strings from 'vs/base/common/strings'; import { URI } from 'vs/base/common/uri'; +import { ILocalizedString } from 'vs/platform/action/common/action'; import { ExtensionKind } from 'vs/platform/environment/common/environment'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { getRemoteName } from 'vs/platform/remote/common/remoteHosts'; @@ -17,8 +18,8 @@ export const UNDEFINED_PUBLISHER = 'undefined_publisher'; export interface ICommand { command: string; - title: string; - category?: string; + title: string | ILocalizedString; + category?: string | ILocalizedString; } export interface IConfigurationProperty { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index 8e4020470415c..604aabbbbb867 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -1545,7 +1545,7 @@ export class ExtensionEditor extends EditorPane { ), ...commands.map(c => $('tr', undefined, $('td', undefined, $('code', undefined, c.id)), - $('td', undefined, c.title), + $('td', undefined, typeof c.title === 'string' ? c.title : c.title.value), $('td', undefined, ...c.keybindings.map(keybinding => renderKeybinding(keybinding))), $('td', undefined, ...c.menus.map(context => $('code', undefined, context))) ))