Skip to content

Commit 3c90c83

Browse files
use common extension manifest string replacer for web and desktop (microsoft#158834)
* use common extension manifest string replacer for web and desktop * add tests and update type accordingly * fix build
1 parent dcc2652 commit 3c90c83

File tree

5 files changed

+186
-85
lines changed

5 files changed

+186
-85
lines changed

src/vs/platform/extensionManagement/common/extensionNls.ts

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,81 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { cloneAndChange } from 'vs/base/common/objects';
6+
import { isObject, isString } from 'vs/base/common/types';
7+
import { ILocalizedString } from 'vs/platform/action/common/action';
78
import { IExtensionManifest } from 'vs/platform/extensions/common/extensions';
8-
9-
const nlsRegex = /^%([\w\d.-]+)%$/i;
9+
import { localize } from 'vs/nls';
1010

1111
export interface ITranslations {
1212
[key: string]: string | { message: string; comment: string[] };
1313
}
1414

15-
export function localizeManifest(manifest: IExtensionManifest, translations: ITranslations, fallbackTranslations?: ITranslations): IExtensionManifest {
16-
const patcher = (value: string): string | undefined => {
17-
if (typeof value !== 'string') {
18-
return undefined;
19-
}
20-
21-
const match = nlsRegex.exec(value);
15+
export function localizeManifest(extensionManifest: IExtensionManifest, translations: ITranslations, fallbackTranslations?: ITranslations): IExtensionManifest {
16+
try {
17+
replaceNLStrings(extensionManifest, translations, fallbackTranslations);
18+
} catch (error) {
19+
/*Ignore Error*/
20+
}
21+
return extensionManifest;
22+
}
2223

23-
if (!match) {
24-
return undefined;
24+
/**
25+
* This routine makes the following assumptions:
26+
* The root element is an object literal
27+
*/
28+
function replaceNLStrings(extensionManifest: IExtensionManifest, messages: ITranslations, originalMessages?: ITranslations): void {
29+
const processEntry = (obj: any, key: string | number, command?: boolean) => {
30+
const value = obj[key];
31+
if (isString(value)) {
32+
const str = <string>value;
33+
const length = str.length;
34+
if (length > 1 && str[0] === '%' && str[length - 1] === '%') {
35+
const messageKey = str.substr(1, length - 2);
36+
let translated = messages[messageKey];
37+
// If the messages come from a language pack they might miss some keys
38+
// Fill them from the original messages.
39+
if (translated === undefined && originalMessages) {
40+
translated = originalMessages[messageKey];
41+
}
42+
const message: string | undefined = typeof translated === 'string' ? translated : translated.message;
43+
if (message !== undefined) {
44+
// This branch returns ILocalizedString's instead of Strings so that the Command Palette can contain both the localized and the original value.
45+
const original = originalMessages?.[messageKey];
46+
const originalMessage: string | undefined = typeof original === 'string' ? original : original?.message;
47+
if (
48+
// if we are translating the title or category of a command
49+
command && (key === 'title' || key === 'category') &&
50+
// and the original value is not the same as the translated value
51+
originalMessage && originalMessage !== message
52+
) {
53+
const localizedString: ILocalizedString = {
54+
value: message,
55+
original: originalMessage
56+
};
57+
obj[key] = localizedString;
58+
} else {
59+
obj[key] = message;
60+
}
61+
} else {
62+
console.warn(`[${extensionManifest.name}]: ${localize('missingNLSKey', "Couldn't find message for key {0}.", messageKey)}`);
63+
}
64+
}
65+
} else if (isObject(value)) {
66+
for (const k in value) {
67+
if (value.hasOwnProperty(k)) {
68+
k === 'commands' ? processEntry(value, k, true) : processEntry(value, k, command);
69+
}
70+
}
71+
} else if (Array.isArray(value)) {
72+
for (let i = 0; i < value.length; i++) {
73+
processEntry(value, i, command);
74+
}
2575
}
26-
27-
const translation = translations?.[match[1]] ?? fallbackTranslations?.[match[1]] ?? value;
28-
return typeof translation === 'string' ? translation : (typeof translation.message === 'string' ? translation.message : value);
2976
};
3077

31-
return cloneAndChange(manifest, patcher);
78+
for (const key in extensionManifest) {
79+
if (extensionManifest.hasOwnProperty(key)) {
80+
processEntry(extensionManifest, key);
81+
}
82+
}
3283
}

src/vs/platform/extensionManagement/common/extensionsScannerService.ts

Lines changed: 5 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import * as platform from 'vs/base/common/platform';
1818
import { basename, isEqual, joinPath } from 'vs/base/common/resources';
1919
import * as semver from 'vs/base/common/semver/semver';
2020
import Severity from 'vs/base/common/severity';
21-
import { isEmptyObject, isObject, isString } from 'vs/base/common/types';
21+
import { isEmptyObject } from 'vs/base/common/types';
2222
import { URI } from 'vs/base/common/uri';
2323
import { localize } from 'vs/nls';
2424
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
@@ -35,7 +35,7 @@ import { revive } from 'vs/base/common/marshalling';
3535
import { IExtensionsProfileScannerService, IScannedProfileExtension } from 'vs/platform/extensionManagement/common/extensionsProfileScannerService';
3636
import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile';
3737
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
38-
import { ILocalizedString } from 'vs/platform/action/common/action';
38+
import { localizeManifest } from 'vs/platform/extensionManagement/common/extensionNls';
3939

4040
export type IScannedExtensionManifest = IRelaxedExtensionManifest & { __metadata?: Metadata };
4141

@@ -658,7 +658,7 @@ class ExtensionsScanner extends Disposable {
658658
return extensionManifest;
659659
}
660660
const localized = localizedMessages.values || Object.create(null);
661-
this.replaceNLStrings(nlsConfiguration.pseudo, extensionManifest, localized, defaults, extensionLocation);
661+
return localizeManifest(extensionManifest, localized, defaults);
662662
} catch (error) {
663663
/*Ignore Error*/
664664
}
@@ -734,18 +734,16 @@ class ExtensionsScanner extends Disposable {
734734
/**
735735
* Parses original message bundle, returns null if the original message bundle is null.
736736
*/
737-
private async resolveOriginalMessageBundle(originalMessageBundle: URI | null, errors: ParseError[]): Promise<{ [key: string]: string } | null> {
737+
private async resolveOriginalMessageBundle(originalMessageBundle: URI | null, errors: ParseError[]): Promise<{ [key: string]: string } | undefined> {
738738
if (originalMessageBundle) {
739739
try {
740740
const originalBundleContent = (await this.fileService.readFile(originalMessageBundle)).value.toString();
741741
return parse(originalBundleContent, errors);
742742
} catch (error) {
743743
/* Ignore Error */
744-
return null;
745744
}
746-
} else {
747-
return null;
748745
}
746+
return;
749747
}
750748

751749
/**
@@ -776,65 +774,6 @@ class ExtensionsScanner extends Disposable {
776774
});
777775
}
778776

779-
/**
780-
* This routine makes the following assumptions:
781-
* The root element is an object literal
782-
*/
783-
private replaceNLStrings<T extends object>(pseudo: boolean, literal: T, messages: MessageBag, originalMessages: MessageBag | null, extensionLocation: URI): void {
784-
const processEntry = (obj: any, key: string | number, command?: boolean) => {
785-
const value = obj[key];
786-
if (isString(value)) {
787-
const str = <string>value;
788-
const length = str.length;
789-
if (length > 1 && str[0] === '%' && str[length - 1] === '%') {
790-
const messageKey = str.substr(1, length - 2);
791-
let translated = messages[messageKey];
792-
// If the messages come from a language pack they might miss some keys
793-
// Fill them from the original messages.
794-
if (translated === undefined && originalMessages) {
795-
translated = originalMessages[messageKey];
796-
}
797-
let message: string | undefined = typeof translated === 'string' ? translated : translated.message;
798-
if (message !== undefined) {
799-
if (pseudo) {
800-
// FF3B and FF3D is the Unicode zenkaku representation for [ and ]
801-
message = '\uFF3B' + message.replace(/[aouei]/g, '$&$&') + '\uFF3D';
802-
}
803-
// This branch returns ILocalizedString's instead of Strings so that the Command Palette can contain both the localized and the original value.
804-
if (command && originalMessages && (key === 'title' || key === 'category')) {
805-
const originalMessage = originalMessages[messageKey];
806-
const localizedString: ILocalizedString = {
807-
value: message,
808-
original: typeof originalMessage === 'string' ? originalMessage : originalMessage?.message
809-
};
810-
obj[key] = localizedString;
811-
} else {
812-
obj[key] = message;
813-
}
814-
} else {
815-
this.logService.warn(this.formatMessage(extensionLocation, localize('missingNLSKey', "Couldn't find message for key {0}.", messageKey)));
816-
}
817-
}
818-
} else if (isObject(value)) {
819-
for (const k in value) {
820-
if (value.hasOwnProperty(k)) {
821-
k === 'commands' ? processEntry(value, k, true) : processEntry(value, k, command);
822-
}
823-
}
824-
} else if (Array.isArray(value)) {
825-
for (let i = 0; i < value.length; i++) {
826-
processEntry(value, i, command);
827-
}
828-
}
829-
};
830-
831-
for (const key in literal) {
832-
if (literal.hasOwnProperty(key)) {
833-
processEntry(literal, key);
834-
}
835-
}
836-
}
837-
838777
private formatMessage(extensionLocation: URI, message: string): string {
839778
return `[${extensionLocation.path}]: ${message}`;
840779
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as assert from 'assert';
7+
import { deepClone } from 'vs/base/common/objects';
8+
import { ILocalizedString } from 'vs/platform/action/common/action';
9+
import { localizeManifest } from 'vs/platform/extensionManagement/common/extensionNls';
10+
import { IExtensionManifest, IConfiguration } from 'vs/platform/extensions/common/extensions';
11+
12+
const manifest: IExtensionManifest = {
13+
name: 'test',
14+
publisher: 'test',
15+
version: '1.0.0',
16+
engines: {
17+
vscode: '*'
18+
},
19+
contributes: {
20+
commands: [
21+
{
22+
command: 'test.command',
23+
title: '%test.command.title%',
24+
category: '%test.command.category%'
25+
},
26+
],
27+
authentication: [
28+
{
29+
id: 'test.authentication',
30+
label: '%test.authentication.label%',
31+
}
32+
],
33+
configuration: {
34+
// to ensure we test another "title" property
35+
title: '%test.configuration.title%',
36+
properties: {
37+
'test.configuration': {
38+
type: 'string',
39+
description: 'not important',
40+
}
41+
}
42+
}
43+
}
44+
};
45+
46+
suite('Localize Manifest', () => {
47+
test('replaces template strings', function () {
48+
const localizedManifest = localizeManifest(
49+
deepClone(manifest),
50+
{
51+
'test.command.title': 'Test Command',
52+
'test.command.category': 'Test Category',
53+
'test.authentication.label': 'Test Authentication',
54+
'test.configuration.title': 'Test Configuration',
55+
}
56+
);
57+
58+
assert.strictEqual(localizedManifest.contributes?.commands?.[0].title, 'Test Command');
59+
assert.strictEqual(localizedManifest.contributes?.commands?.[0].category, 'Test Category');
60+
assert.strictEqual(localizedManifest.contributes?.authentication?.[0].label, 'Test Authentication');
61+
assert.strictEqual((localizedManifest.contributes?.configuration as IConfiguration).title, 'Test Configuration');
62+
});
63+
64+
test('replaces template strings with fallback if not found in translations', function () {
65+
const localizedManifest = localizeManifest(
66+
deepClone(manifest),
67+
{},
68+
{
69+
'test.command.title': 'Test Command',
70+
'test.command.category': 'Test Category',
71+
'test.authentication.label': 'Test Authentication',
72+
'test.configuration.title': 'Test Configuration',
73+
}
74+
);
75+
76+
assert.strictEqual(localizedManifest.contributes?.commands?.[0].title, 'Test Command');
77+
assert.strictEqual(localizedManifest.contributes?.commands?.[0].category, 'Test Category');
78+
assert.strictEqual(localizedManifest.contributes?.authentication?.[0].label, 'Test Authentication');
79+
assert.strictEqual((localizedManifest.contributes?.configuration as IConfiguration).title, 'Test Configuration');
80+
});
81+
82+
test('replaces template strings - command title & categories become ILocalizedString', function () {
83+
const localizedManifest = localizeManifest(
84+
deepClone(manifest),
85+
{
86+
'test.command.title': 'Befehl test',
87+
'test.command.category': 'Testkategorie',
88+
'test.authentication.label': 'Testauthentifizierung',
89+
'test.configuration.title': 'Testkonfiguration',
90+
},
91+
{
92+
'test.command.title': 'Test Command',
93+
'test.command.category': 'Test Category',
94+
'test.authentication.label': 'Test Authentication',
95+
'test.configuration.title': 'Test Configuration',
96+
}
97+
);
98+
99+
const title = localizedManifest.contributes?.commands?.[0].title as ILocalizedString;
100+
const category = localizedManifest.contributes?.commands?.[0].category as ILocalizedString;
101+
assert.strictEqual(title.value, 'Befehl test');
102+
assert.strictEqual(title.original, 'Test Command');
103+
assert.strictEqual(category.value, 'Testkategorie');
104+
assert.strictEqual(category.original, 'Test Category');
105+
106+
// Everything else stays as a string.
107+
assert.strictEqual(localizedManifest.contributes?.authentication?.[0].label, 'Testauthentifizierung');
108+
assert.strictEqual((localizedManifest.contributes?.configuration as IConfiguration).title, 'Testkonfiguration');
109+
});
110+
});

src/vs/platform/extensions/common/extensions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import Severity from 'vs/base/common/severity';
77
import * as strings from 'vs/base/common/strings';
88
import { URI } from 'vs/base/common/uri';
9+
import { ILocalizedString } from 'vs/platform/action/common/action';
910
import { ExtensionKind } from 'vs/platform/environment/common/environment';
1011
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
1112
import { getRemoteName } from 'vs/platform/remote/common/remoteHosts';
@@ -17,8 +18,8 @@ export const UNDEFINED_PUBLISHER = 'undefined_publisher';
1718

1819
export interface ICommand {
1920
command: string;
20-
title: string;
21-
category?: string;
21+
title: string | ILocalizedString;
22+
category?: string | ILocalizedString;
2223
}
2324

2425
export interface IConfigurationProperty {

src/vs/workbench/contrib/extensions/browser/extensionEditor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1545,7 +1545,7 @@ export class ExtensionEditor extends EditorPane {
15451545
),
15461546
...commands.map(c => $('tr', undefined,
15471547
$('td', undefined, $('code', undefined, c.id)),
1548-
$('td', undefined, c.title),
1548+
$('td', undefined, typeof c.title === 'string' ? c.title : c.title.value),
15491549
$('td', undefined, ...c.keybindings.map(keybinding => renderKeybinding(keybinding))),
15501550
$('td', undefined, ...c.menus.map(context => $('code', undefined, context)))
15511551
))

0 commit comments

Comments
 (0)