feat: add plugin localization contract

This commit is contained in:
mirivlad 2026-07-11 01:58:20 +08:00
parent ad2dd975e7
commit d69214ea18
14 changed files with 157 additions and 7 deletions

View File

@ -1,6 +1,8 @@
import type { CapabilityEntry, FileBytes, FileEntry, FileMetadata, MovePathOptions, OpenResourceRequest, OpenResourceResult, PluginSettings, RegisteredContributionPoints, RestoreTrashOptions, TrashEntry, TrashResult, WriteTextOptions } from './types'; import type { CapabilityEntry, FileBytes, FileEntry, FileMetadata, MovePathOptions, OpenResourceRequest, OpenResourceResult, PluginSettings, RegisteredContributionPoints, RestoreTrashOptions, TrashEntry, TrashResult, WriteTextOptions } from './types';
export type PluginCommandArgs = Record<string, unknown>; export type PluginCommandArgs = Record<string, unknown>;
export type PluginDataJSON = Record<string, unknown>; export type PluginDataJSON = Record<string, unknown>;
export type PluginLocale = 'ru' | 'en';
export type TranslationParams = Record<string, string | number>;
export type PluginCommandHandler = (args: PluginCommandArgs, declaration: PluginCommandDeclaration) => unknown | Promise<unknown>; export type PluginCommandHandler = (args: PluginCommandArgs, declaration: PluginCommandDeclaration) => unknown | Promise<unknown>;
export type Unsubscribe = () => void; export type Unsubscribe = () => void;
export interface PluginCommandDeclaration { export interface PluginCommandDeclaration {
@ -61,6 +63,11 @@ export interface BrowserReceiverPairing {
} }
export interface VerstakPluginAPI { export interface VerstakPluginAPI {
readonly pluginId: string; readonly pluginId: string;
i18n: {
getLocale(): PluginLocale;
t(key: string, params?: TranslationParams, fallback?: string): string;
onDidChangeLocale(listener: (locale: PluginLocale) => void): Unsubscribe;
};
settings: { settings: {
read(): Promise<PluginSettings>; read(): Promise<PluginSettings>;
read<T = unknown>(key: string): Promise<T | undefined>; read<T = unknown>(key: string): Promise<T | undefined>;

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
{"version":3,"file":"plugin-api.js","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAC7D,EAAE;AACF,8EAA8E;AAC9E,gFAAgF;AAChF,8EAA8E;AAC9E,gDAAgD;AAsLhD,MAAM,UAAU,eAAe,CAAC,SAAiB;IAC/C,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;AACtF,CAAC"} {"version":3,"file":"plugin-api.js","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAC7D,EAAE;AACF,8EAA8E;AAC9E,gFAAgF;AAChF,8EAA8E;AAC9E,gDAAgD;AA8LhD,MAAM,UAAU,eAAe,CAAC,SAAiB;IAC/C,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;AACtF,CAAC"}

View File

@ -1,7 +1,10 @@
import type { PluginManifest, PluginState, RegisteredContributionPoints } from './types'; import type { PluginManifest, PluginState, RegisteredContributionPoints } from './types';
import type { VerstakPluginAPI } from './plugin-api'; import type { PluginLocale, VerstakPluginAPI } from './plugin-api';
export interface MockPluginAPIOptions { export interface MockPluginAPIOptions {
contributions?: RegisteredContributionPoints; contributions?: RegisteredContributionPoints;
locale?: PluginLocale;
defaultLocale?: PluginLocale;
messages?: Partial<Record<PluginLocale, Record<string, string>>>;
} }
/** /**
* Создать тестовый manifest для unit-тестов. * Создать тестовый manifest для unit-тестов.

View File

@ -1 +1 @@
{"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAC;AACzF,OAAO,KAAK,EAAwB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAQ3E,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,4BAA4B,CAAC;CAC9C;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAetF;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CASnF;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,SAAgB,EAAE,OAAO,GAAE,oBAAyB,GAAG,gBAAgB,CA4WlH;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAgCxF;AAGD,OAAO,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,EAAE,EAAE,CAAC"} {"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAC;AACzF,OAAO,KAAK,EAAwB,YAAY,EAAqB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAQ5G,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,4BAA4B,CAAC;IAC7C,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;CAClE;AASD;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAetF;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CASnF;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,SAAgB,EAAE,OAAO,GAAE,oBAAyB,GAAG,gBAAgB,CA0XlH;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAgCxF;AAGD,OAAO,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,EAAE,EAAE,CAAC"}

19
dist/test-utils.js vendored
View File

@ -3,6 +3,11 @@ const mockCommandHandlers = new Map();
function commandKey(pluginId, commandId) { function commandKey(pluginId, commandId) {
return `${pluginId}:${commandId}`; return `${pluginId}:${commandId}`;
} }
function interpolateMessage(message, params) {
if (!params)
return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => (Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder));
}
/** /**
* Создать тестовый manifest для unit-тестов. * Создать тестовый manifest для unit-тестов.
*/ */
@ -39,6 +44,9 @@ export function createTestPluginState(overrides) {
* Создать заглушку VerstakPluginAPI для тестов. * Создать заглушку VerstakPluginAPI для тестов.
*/ */
export function createMockPluginAPI(pluginId = 'test.plugin', options = {}) { export function createMockPluginAPI(pluginId = 'test.plugin', options = {}) {
const locale = options.locale || 'en';
const defaultLocale = options.defaultLocale || 'en';
const messages = options.messages || {};
const settings = {}; const settings = {};
const pluginData = new Map(); const pluginData = new Map();
const commands = new Map(); const commands = new Map();
@ -129,6 +137,17 @@ export function createMockPluginAPI(pluginId = 'test.plugin', options = {}) {
} }
return { return {
pluginId, pluginId,
i18n: {
getLocale: vi.fn(() => locale),
t: vi.fn((key, params, fallback) => {
const message = messages[locale]?.[key]
?? messages[defaultLocale]?.[key]
?? fallback
?? key;
return interpolateMessage(message, params);
}),
onDidChangeLocale: vi.fn((_listener) => () => { }),
},
settings: { settings: {
read: vi.fn(async (key) => key ? settings[key] : { ...settings }), read: vi.fn(async (key) => key ? settings[key] : { ...settings }),
write: vi.fn(async (key, value) => { write: vi.fn(async (key, value) => {

File diff suppressed because one or more lines are too long

5
dist/types.d.ts vendored
View File

@ -1,4 +1,8 @@
export type PluginSource = 'official' | 'local' | 'third-party'; export type PluginSource = 'official' | 'local' | 'third-party';
export interface PluginLocalizationConfig {
defaultLocale: string;
locales: Record<string, string>;
}
export interface PluginManifest { export interface PluginManifest {
schemaVersion: 1; schemaVersion: 1;
id: string; id: string;
@ -8,6 +12,7 @@ export interface PluginManifest {
description?: string; description?: string;
source?: PluginSource; source?: PluginSource;
icon?: string; icon?: string;
localization?: PluginLocalizationConfig;
provides: string[]; provides: string[];
requires?: string[]; requires?: string[];
optionalRequires?: string[]; optionalRequires?: string[];

2
dist/types.d.ts.map vendored

File diff suppressed because one or more lines are too long

View File

@ -51,6 +51,31 @@
"type": "string", "type": "string",
"description": "Path to plugin icon (relative to plugin root)" "description": "Path to plugin icon (relative to plugin root)"
}, },
"localization": {
"type": "object",
"description": "Plugin-owned localization catalogs",
"properties": {
"defaultLocale": {
"type": "string",
"description": "Default locale used when the selected locale is unavailable",
"pattern": "^[a-z]{2}(?:-[a-z0-9]+)*$"
},
"locales": {
"type": "object",
"description": "Locale tags mapped to plugin-relative JSON catalog paths",
"propertyNames": {
"pattern": "^[a-z]{2}(?:-[a-z0-9]+)*$"
},
"additionalProperties": {
"type": "string",
"pattern": "^(?![\\\\/])(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
},
"minProperties": 1
}
},
"required": ["defaultLocale", "locales"],
"additionalProperties": false
},
"provides": { "provides": {
"type": "array", "type": "array",
"description": "Capabilities this plugin provides", "description": "Capabilities this plugin provides",

View File

@ -16,6 +16,9 @@ describe('VerstakPluginAPI contract', () => {
expect(typeof api.settings.write).toBe('function'); expect(typeof api.settings.write).toBe('function');
expect(typeof api.storage.data.read).toBe('function'); expect(typeof api.storage.data.read).toBe('function');
expect(typeof api.storage.data.write).toBe('function'); expect(typeof api.storage.data.write).toBe('function');
expect(typeof api.i18n.getLocale).toBe('function');
expect(typeof api.i18n.t).toBe('function');
expect(typeof api.i18n.onDidChangeLocale).toBe('function');
expect(typeof api.ui.openSettings).toBe('function'); expect(typeof api.ui.openSettings).toBe('function');
expect(typeof api.capabilities.list).toBe('function'); expect(typeof api.capabilities.list).toBe('function');
expect(typeof api.commands.register).toBe('function'); expect(typeof api.commands.register).toBe('function');
@ -58,6 +61,56 @@ describe('VerstakPluginAPI contract', () => {
expect(permissionEnum).toContain('workbench.open'); expect(permissionEnum).toContain('workbench.open');
}); });
test('manifest schema declares safe plugin localization catalogs', () => {
const localization = (manifestSchema as any).properties.localization;
expect(localization.type).toBe('object');
expect(localization.required).toEqual(['defaultLocale', 'locales']);
expect(localization.properties.defaultLocale.pattern).toBe('^[a-z]{2}(?:-[a-z0-9]+)*$');
expect(localization.properties.locales.propertyNames.pattern).toBe('^[a-z]{2}(?:-[a-z0-9]+)*$');
expect(localization.properties.locales.additionalProperties.pattern).toBe('^(?![\\\\/])(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$');
});
test('manifest types accept plugin-owned localization catalogs', () => {
const manifest: PluginManifest = {
schemaVersion: 1,
id: 'localized.plugin',
name: 'Localized Plugin',
version: '0.1.0',
apiVersion: '0.1.0',
provides: ['localized.example'],
permissions: ['ui.register'],
localization: {
defaultLocale: 'en',
locales: {
en: 'locales/en.json',
ru: 'locales/ru.json',
},
},
};
expect(manifest.localization?.defaultLocale).toBe('en');
expect(manifest.localization?.locales.ru).toBe('locales/ru.json');
});
test('mock i18n translates with fallback and named interpolation', () => {
const api = createMockPluginAPI('localized.plugin', {
locale: 'ru',
messages: {
en: { greeting: 'Hello, {name}!', onlyEnglish: 'English fallback' },
ru: { greeting: 'Привет, {name}!' },
},
defaultLocale: 'en',
});
expect(api.i18n.getLocale()).toBe('ru');
expect(api.i18n.t('greeting', { name: 'Мир' })).toBe('Привет, Мир!');
expect(api.i18n.t('onlyEnglish')).toBe('English fallback');
expect(api.i18n.t('missing', undefined, 'Fallback')).toBe('Fallback');
expect(api.i18n.t('unknown')).toBe('unknown');
expect(typeof api.i18n.onDidChangeLocale(() => {})).toBe('function');
});
test('secrets capability and permissions are declared as dangerous platform contract', () => { test('secrets capability and permissions are declared as dangerous platform contract', () => {
const capabilities = ((capabilitiesSchema as any).capabilities || []) as Array<{ name: string; status: string }>; const capabilities = ((capabilitiesSchema as any).capabilities || []) as Array<{ name: string; status: string }>;
const permissions = ((permissionsSchema as any).permissions || []) as Array<{ name: string; dangerous: boolean }>; const permissions = ((permissionsSchema as any).permissions || []) as Array<{ name: string; dangerous: boolean }>;

View File

@ -23,6 +23,8 @@ import type {
export type PluginCommandArgs = Record<string, unknown>; export type PluginCommandArgs = Record<string, unknown>;
export type PluginDataJSON = Record<string, unknown>; export type PluginDataJSON = Record<string, unknown>;
export type PluginLocale = 'ru' | 'en';
export type TranslationParams = Record<string, string | number>;
export type PluginCommandHandler = ( export type PluginCommandHandler = (
args: PluginCommandArgs, args: PluginCommandArgs,
declaration: PluginCommandDeclaration declaration: PluginCommandDeclaration
@ -95,6 +97,12 @@ export interface BrowserReceiverPairing {
export interface VerstakPluginAPI { export interface VerstakPluginAPI {
readonly pluginId: string; readonly pluginId: string;
i18n: {
getLocale(): PluginLocale;
t(key: string, params?: TranslationParams, fallback?: string): string;
onDidChangeLocale(listener: (locale: PluginLocale) => void): Unsubscribe;
};
settings: { settings: {
read(): Promise<PluginSettings>; read(): Promise<PluginSettings>;
read<T = unknown>(key: string): Promise<T | undefined>; read<T = unknown>(key: string): Promise<T | undefined>;

View File

@ -1,7 +1,7 @@
// Verstak Plugin SDK — Test Utilities // Verstak Plugin SDK — Test Utilities
import type { PluginManifest, PluginState, RegisteredContributionPoints } from './types'; import type { PluginManifest, PluginState, RegisteredContributionPoints } from './types';
import type { PluginCommandHandler, VerstakPluginAPI } from './plugin-api'; import type { PluginCommandHandler, PluginLocale, TranslationParams, VerstakPluginAPI } from './plugin-api';
const mockCommandHandlers = new Map<string, PluginCommandHandler>(); const mockCommandHandlers = new Map<string, PluginCommandHandler>();
@ -11,6 +11,16 @@ function commandKey(pluginId: string, commandId: string): string {
export interface MockPluginAPIOptions { export interface MockPluginAPIOptions {
contributions?: RegisteredContributionPoints; contributions?: RegisteredContributionPoints;
locale?: PluginLocale;
defaultLocale?: PluginLocale;
messages?: Partial<Record<PluginLocale, Record<string, string>>>;
}
function interpolateMessage(message: string, params?: TranslationParams): string {
if (!params) return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name: string) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder
));
} }
/** /**
@ -51,6 +61,9 @@ export function createTestPluginState(overrides?: Partial<PluginState>): PluginS
* Создать заглушку VerstakPluginAPI для тестов. * Создать заглушку VerstakPluginAPI для тестов.
*/ */
export function createMockPluginAPI(pluginId = 'test.plugin', options: MockPluginAPIOptions = {}): VerstakPluginAPI { export function createMockPluginAPI(pluginId = 'test.plugin', options: MockPluginAPIOptions = {}): VerstakPluginAPI {
const locale = options.locale || 'en';
const defaultLocale = options.defaultLocale || 'en';
const messages = options.messages || {};
const settings: Record<string, unknown> = {}; const settings: Record<string, unknown> = {};
const pluginData = new Map<string, Record<string, unknown>>(); const pluginData = new Map<string, Record<string, unknown>>();
const commands = new Map<string, PluginCommandHandler>(); const commands = new Map<string, PluginCommandHandler>();
@ -139,6 +152,17 @@ export function createMockPluginAPI(pluginId = 'test.plugin', options: MockPlugi
return { return {
pluginId, pluginId,
i18n: {
getLocale: vi.fn(() => locale),
t: vi.fn((key: string, params?: TranslationParams, fallback?: string) => {
const message = messages[locale]?.[key]
?? messages[defaultLocale]?.[key]
?? fallback
?? key;
return interpolateMessage(message, params);
}),
onDidChangeLocale: vi.fn((_listener: (nextLocale: PluginLocale) => void) => () => {}),
},
settings: { settings: {
read: vi.fn(async (key?: string) => key ? settings[key] : { ...settings }) as VerstakPluginAPI['settings']['read'], read: vi.fn(async (key?: string) => key ? settings[key] : { ...settings }) as VerstakPluginAPI['settings']['read'],
write: vi.fn(async (key: string, value: unknown) => { write: vi.fn(async (key: string, value: unknown) => {

View File

@ -4,6 +4,11 @@
export type PluginSource = 'official' | 'local' | 'third-party'; export type PluginSource = 'official' | 'local' | 'third-party';
export interface PluginLocalizationConfig {
defaultLocale: string;
locales: Record<string, string>;
}
export interface PluginManifest { export interface PluginManifest {
schemaVersion: 1; schemaVersion: 1;
id: string; id: string;
@ -13,6 +18,7 @@ export interface PluginManifest {
description?: string; description?: string;
source?: PluginSource; source?: PluginSource;
icon?: string; icon?: string;
localization?: PluginLocalizationConfig;
provides: string[]; provides: string[];
requires?: string[]; requires?: string[];
optionalRequires?: string[]; optionalRequires?: string[];