Skip to content

Commit

Permalink
use common extension manifest string replacer for web and desktop (mi…
Browse files Browse the repository at this point in the history
…crosoft#158834)

* use common extension manifest string replacer for web and desktop

* add tests and update type accordingly

* fix build
  • Loading branch information
TylerLeonhardt authored Aug 23, 2022
1 parent dcc2652 commit 3c90c83
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 85 deletions.
83 changes: 67 additions & 16 deletions src/vs/platform/extensionManagement/common/extensionNls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <string>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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 };

Expand Down Expand Up @@ -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*/
}
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -776,65 +774,6 @@ class ExtensionsScanner extends Disposable {
});
}

/**
* This routine makes the following assumptions:
* The root element is an object literal
*/
private replaceNLStrings<T extends object>(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 = <string>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}`;
}
Expand Down
110 changes: 110 additions & 0 deletions src/vs/platform/extensionManagement/test/common/extensionNls.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
5 changes: 3 additions & 2 deletions src/vs/platform/extensions/common/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
))
Expand Down

0 comments on commit 3c90c83

Please sign in to comment.