feat(plugins): support localized settings fields and options
This commit is contained in:
1 parent
b89618b1e3
commit
4d159bca5e
9 files changed
+162
-9
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { SETTINGS_ITEM_TITLE_CLASSNAME } from "@/components/settings/settings-ui";
|
||||
import { groupPluginSettingFields } from "./plugin-settings-layout";
|
||||
import { localizePluginSettingField } from "./plugin-settings-localization";
|
||||
|
||||
const PluginSettingListDialog = ({ field }: { field: PluginSettingField }) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -168,10 +169,11 @@ const PluginSettingFieldRow = ({
|
||||
};
|
||||
|
||||
export const PluginSettingsSection = ({ host, manifest }: { host: EdgeEverPluginHost; manifest: PluginManifest }) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const formId = useId();
|
||||
const fields = manifest.settings?.fields ?? [];
|
||||
const fieldGroups = groupPluginSettingFields(fields);
|
||||
const localizedFields = fields.map((field) => localizePluginSettingField(field, i18n.resolvedLanguage ?? i18n.language));
|
||||
const fieldGroups = groupPluginSettingFields(localizedFields);
|
||||
const fieldLoadSignature = pluginSettingLoadSignature(fields);
|
||||
const fieldsRef = useRef(fields);
|
||||
fieldsRef.current = fields;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { localizePluginSettingField } from "./plugin-settings-localization";
|
||||
|
||||
describe("plugin setting localization", () => {
|
||||
const field = {
|
||||
key: "format", type: "select", label: "Format", default: "text",
|
||||
options: [{ value: "text", label: "Text" }, { value: "emoji", label: "Emoji" }],
|
||||
locales: {
|
||||
"zh-CN": { label: "格式", options: { text: "文本" } },
|
||||
ja: { label: "形式", options: { text: "テキスト" } },
|
||||
},
|
||||
};
|
||||
|
||||
test("uses exact locale, then matching language, then base copy", () => {
|
||||
expect(localizePluginSettingField(field, "zh-CN")).toMatchObject({ label: "格式", options: [{ value: "text", label: "文本" }, { value: "emoji", label: "Emoji" }] });
|
||||
expect(localizePluginSettingField(field, "ja-JP").label).toBe("形式");
|
||||
expect(localizePluginSettingField(field, "fr-FR")).toBe(field);
|
||||
});
|
||||
|
||||
test("keeps setting keys and values while translating text", () => {
|
||||
const localized = localizePluginSettingField(field, "zh-CN");
|
||||
expect(localized.key).toBe(field.key);
|
||||
expect(localized.default).toBe(field.default);
|
||||
expect(localized.options.map(({ value }) => value)).toEqual(["text", "emoji"]);
|
||||
expect(field.label).toBe("Format");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PluginSettingField } from "@edgeever/plugin-api";
|
||||
|
||||
export const localizePluginSettingField = (field: PluginSettingField, locale: string | undefined): PluginSettingField => {
|
||||
if (!locale || !field.locales) return field;
|
||||
const normalized = locale.trim().replaceAll("_", "-").toLocaleLowerCase();
|
||||
const entries = Object.entries(field.locales);
|
||||
const exact = entries.find(([candidate]) => candidate.toLocaleLowerCase() === normalized)?.[1];
|
||||
const language = normalized.split("-")[0];
|
||||
const copy = exact ?? entries.find(([candidate]) => candidate.toLocaleLowerCase().split("-")[0] === language)?.[1];
|
||||
if (!copy) return field;
|
||||
return {
|
||||
...field,
|
||||
label: copy.label ?? field.label,
|
||||
description: copy.description ?? field.description,
|
||||
...((field.type === "text" || field.type === "secret") && copy.placeholder ? { placeholder: copy.placeholder } : {}),
|
||||
...(field.type === "select" && copy.options ? {
|
||||
options: field.options.map((option) => ({ ...option, label: copy.options?.[option.value] ?? option.label })),
|
||||
} : {}),
|
||||
} as PluginSettingField;
|
||||
};
|
||||
@@ -37,7 +37,7 @@ Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bind
|
||||
|
||||
The manifest and JavaScript module must be served with CORS headers that permit the EdgeEver origin. Relative `entry` paths resolve against the manifest URL.
|
||||
|
||||
The top-level `name` and optional `description` remain the fallback copy. Plugins and themes can add a `locales` object keyed by BCP 47 language tags, such as `zh-CN`, `en-US`, or `ja`. Each locale can override `name`, `description`, or both. EdgeEver first matches the current interface locale, then the same base language, and finally falls back to the top-level fields. This localizes marketplace and plugin-manager metadata; runtime commands, panels, notices, and host-rendered setting labels remain the plugin's responsibility.
|
||||
The top-level `name` and optional `description` remain the fallback copy. Plugins and themes can add a `locales` object keyed by BCP 47 language tags, such as `zh-CN`, `en-US`, or `ja`. Each locale can override `name`, `description`, or both. EdgeEver first matches the current interface locale, then the same base language, and finally falls back to the top-level fields. This localizes marketplace and plugin-manager metadata. Plugin setting fields can also declare `locales` with translated `label`, `description`, and (for text inputs) `placeholder`; select fields can translate option labels with an `options` object keyed by option value. The host selects setting copy using the same locale fallback. Runtime commands, panels, and notices remain the plugin's responsibility.
|
||||
|
||||
## GitHub distribution
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。
|
||||
|
||||
Manifest 和 JavaScript 模块必须返回允许 EdgeEver 来源访问的 CORS 响应头。相对 `entry` 地址基于 Manifest 地址解析。
|
||||
|
||||
顶层必填的 `name` 与可选的 `description` 保持为回退文案。插件与主题可以增加以 BCP 47 语言标签为键的 `locales` 对象,例如 `zh-CN`、`en-US` 或 `ja`;每种语言可覆盖 `name`、`description` 或两者。EdgeEver 会先匹配当前界面语言,再匹配相同基础语言,最后回退到顶层字段。这里本地化的是插件市场与插件管理页的元数据;运行时命令、面板、通知及宿主渲染的设置项标签仍由插件自行负责本地化。
|
||||
顶层必填的 `name` 与可选的 `description` 保持为回退文案。插件与主题可以增加以 BCP 47 语言标签为键的 `locales` 对象,例如 `zh-CN`、`en-US` 或 `ja`;每种语言可覆盖 `name`、`description` 或两者。EdgeEver 会先匹配当前界面语言,再匹配相同基础语言,最后回退到顶层字段。这里本地化的是插件市场与插件管理页的元数据。插件设置字段也可通过 `locales` 提供翻译后的 `label`、`description` 和(文本输入框的)`placeholder`;下拉选项可使用以选项值为键的 `options` 对象翻译标签。宿主按相同规则选择设置文案。运行时命令、面板和通知仍由插件自行负责本地化。
|
||||
|
||||
## 通过 GitHub 分发
|
||||
|
||||
|
||||
Vendored
+6
@@ -43,6 +43,12 @@ interface PluginSettingBase {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
locales?: Record<string, {
|
||||
label?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
options?: Record<string, string>;
|
||||
}>;
|
||||
required?: boolean;
|
||||
/** Host-rendered read-only items, opened from a small entry next to the field. */
|
||||
list?: PluginSettingList;
|
||||
|
||||
Vendored
+53
-4
@@ -247,6 +247,52 @@ var normalizeThemeTokens = (value) => {
|
||||
return tokens;
|
||||
};
|
||||
var SETTING_KEY_PATTERN = /^[a-z][a-z0-9._-]*$/;
|
||||
var normalizeSettingLocales = (value, key, type, optionValues) => {
|
||||
if (value === undefined)
|
||||
return;
|
||||
if (!isRecord(value) || Object.keys(value).length > 50)
|
||||
throw new Error(`Plugin setting ${key} locales must be an object with at most 50 entries.`);
|
||||
const locales = {};
|
||||
for (const [rawLocale, copy] of Object.entries(value)) {
|
||||
let locale;
|
||||
try {
|
||||
locale = Intl.getCanonicalLocales(rawLocale.trim().replaceAll("_", "-"))[0] ?? "";
|
||||
} catch {
|
||||
locale = "";
|
||||
}
|
||||
if (!locale || Object.keys(locales).some((existing) => existing.toLocaleLowerCase() === locale.toLocaleLowerCase())) {
|
||||
throw new Error(`Plugin setting ${key} has an invalid or duplicate locale: ${rawLocale}.`);
|
||||
}
|
||||
if (!isRecord(copy))
|
||||
throw new Error(`Plugin setting ${key} locale ${locale} must be an object.`);
|
||||
const localized = {};
|
||||
for (const property of ["label", "description", "placeholder"]) {
|
||||
if (copy[property] === undefined)
|
||||
continue;
|
||||
const limit = property === "description" ? 1000 : 200;
|
||||
if (typeof copy[property] !== "string" || !copy[property].trim() || copy[property].length > limit || property === "placeholder" && type !== "text" && type !== "secret") {
|
||||
throw new Error(`Plugin setting ${key} locale ${locale} has an invalid ${property}.`);
|
||||
}
|
||||
localized[property] = copy[property].trim();
|
||||
}
|
||||
if (copy.options !== undefined) {
|
||||
if (type !== "select" || !isRecord(copy.options) || Object.keys(copy.options).some((option) => !optionValues.includes(option))) {
|
||||
throw new Error(`Plugin setting ${key} locale ${locale} has invalid options.`);
|
||||
}
|
||||
const translatedOptions = [];
|
||||
for (const [option, label] of Object.entries(copy.options)) {
|
||||
if (typeof label !== "string" || !label.trim() || label.length > 200)
|
||||
throw new Error(`Plugin setting ${key} locale ${locale} has an invalid option label.`);
|
||||
translatedOptions.push([option, label.trim()]);
|
||||
}
|
||||
localized.options = Object.fromEntries(translatedOptions);
|
||||
}
|
||||
if (Object.keys(localized).length === 0)
|
||||
throw new Error(`Plugin setting ${key} locale ${locale} must include translated copy.`);
|
||||
locales[locale] = localized;
|
||||
}
|
||||
return locales;
|
||||
};
|
||||
var normalizeSettingList = (field, key) => {
|
||||
if (field.list === undefined)
|
||||
return;
|
||||
@@ -295,9 +341,12 @@ var normalizePluginSettings = (value) => {
|
||||
if (typeof field.description === "string" && field.description.length > 1000)
|
||||
throw new Error(`Plugin setting ${field.key} description is too long.`);
|
||||
const list = normalizeSettingList(field, field.key);
|
||||
const optionValues = field.type === "select" && Array.isArray(field.options) ? field.options.filter(isRecord).map((option) => String(option.value)) : [];
|
||||
const locales = normalizeSettingLocales(field.locales, field.key, String(field.type), optionValues);
|
||||
const common = {
|
||||
key: field.key,
|
||||
label: field.label.trim(),
|
||||
...locales ? { locales } : {},
|
||||
...typeof field.description === "string" && field.description.trim() ? { description: field.description.trim() } : {},
|
||||
...field.required === true ? { required: true } : {},
|
||||
...list ? { list } : {}
|
||||
@@ -339,17 +388,17 @@ var normalizePluginSettings = (value) => {
|
||||
if (field.type === "select") {
|
||||
if (!Array.isArray(field.options) || field.options.length === 0 || field.options.length > 100)
|
||||
throw new Error(`Plugin setting ${field.key} requires between 1 and 100 select options.`);
|
||||
const optionValues = new Set;
|
||||
const optionValues2 = new Set;
|
||||
const options = field.options.map((option) => {
|
||||
if (!isRecord(option) || typeof option.value !== "string" || !option.value || typeof option.label !== "string" || !option.label.trim()) {
|
||||
throw new Error(`Plugin setting ${field.key} has an invalid select option.`);
|
||||
}
|
||||
if (optionValues.has(option.value))
|
||||
if (optionValues2.has(option.value))
|
||||
throw new Error(`Plugin setting ${field.key} has a duplicate select value.`);
|
||||
optionValues.add(option.value);
|
||||
optionValues2.add(option.value);
|
||||
return { value: option.value, label: option.label.trim() };
|
||||
});
|
||||
if (field.default !== undefined && (typeof field.default !== "string" || !optionValues.has(field.default)))
|
||||
if (field.default !== undefined && (typeof field.default !== "string" || !optionValues2.has(field.default)))
|
||||
throw new Error(`Plugin setting ${field.key} default must match a select option.`);
|
||||
return { ...common, type: "select", options, ...typeof field.default === "string" ? { default: field.default } : {} };
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ describe("extension manifests", () => {
|
||||
key: "endpoint",
|
||||
type: "text",
|
||||
label: "Endpoint",
|
||||
locales: { "zh-CN": { label: "服务地址", description: "连接地址", placeholder: "请输入地址" } },
|
||||
default: "https://example.com",
|
||||
className: "plugin-owned-layout",
|
||||
style: { color: "red" },
|
||||
@@ -166,7 +167,7 @@ describe("extension manifests", () => {
|
||||
{ key: "token", type: "secret", label: "Token", required: true },
|
||||
{ key: "limit", type: "number", label: "Limit", default: 10, min: 1, max: 100 },
|
||||
{ key: "enabled", type: "boolean", label: "Enabled", default: true },
|
||||
{ key: "format", type: "select", label: "Format", options: [{ value: "md", label: "Markdown" }] },
|
||||
{ key: "format", type: "select", label: "Format", options: [{ value: "md", label: "Markdown" }], locales: { ja: { label: "形式", options: { md: "マークダウン" } } } },
|
||||
{
|
||||
key: "topics.ai",
|
||||
type: "boolean",
|
||||
@@ -190,6 +191,8 @@ describe("extension manifests", () => {
|
||||
expect(manifest.type).toBe("plugin");
|
||||
expect(manifest.settings?.fields).toHaveLength(6);
|
||||
expect(manifest.settings?.fields[0]).toMatchObject({ key: "endpoint", default: "https://example.com" });
|
||||
expect(manifest.settings?.fields[0].locales?.["zh-CN"]).toEqual({ label: "服务地址", description: "连接地址", placeholder: "请输入地址" });
|
||||
expect(manifest.settings?.fields[4].locales?.ja?.options).toEqual({ md: "マークダウン" });
|
||||
expect(manifest.settings?.fields[0]).not.toHaveProperty("className");
|
||||
expect(manifest.settings?.fields[0]).not.toHaveProperty("style");
|
||||
expect(manifest.settings?.fields[0]).not.toHaveProperty("html");
|
||||
@@ -228,6 +231,10 @@ describe("extension manifests", () => {
|
||||
...base,
|
||||
settings: { fields: [{ key: "topics.ai", type: "boolean", label: "AI", list: { items: [] } }] },
|
||||
})).toThrow("between 1 and 100 items");
|
||||
expect(() => parseExtensionManifest({
|
||||
...base,
|
||||
settings: { fields: [{ key: "mode", type: "select", label: "Mode", options: [{ value: "a", label: "A" }], locales: { ja: { options: { missing: "不明" } } } }] },
|
||||
})).toThrow("invalid options");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ interface PluginSettingBase {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
locales?: Record<string, { label?: string; description?: string; placeholder?: string; options?: Record<string, string> }>;
|
||||
required?: boolean;
|
||||
/** Host-rendered read-only items, opened from a small entry next to the field. */
|
||||
list?: PluginSettingList;
|
||||
@@ -760,6 +761,43 @@ const normalizeThemeTokens = (value: unknown): ThemeTokens => {
|
||||
|
||||
const SETTING_KEY_PATTERN = /^[a-z][a-z0-9._-]*$/;
|
||||
|
||||
const normalizeSettingLocales = (value: unknown, key: string, type: string, optionValues: string[]) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value) || Object.keys(value).length > 50) throw new Error(`Plugin setting ${key} locales must be an object with at most 50 entries.`);
|
||||
const locales: NonNullable<PluginSettingBase["locales"]> = {};
|
||||
for (const [rawLocale, copy] of Object.entries(value)) {
|
||||
let locale: string;
|
||||
try { locale = Intl.getCanonicalLocales(rawLocale.trim().replaceAll("_", "-"))[0] ?? ""; } catch { locale = ""; }
|
||||
if (!locale || Object.keys(locales).some((existing) => existing.toLocaleLowerCase() === locale.toLocaleLowerCase())) {
|
||||
throw new Error(`Plugin setting ${key} has an invalid or duplicate locale: ${rawLocale}.`);
|
||||
}
|
||||
if (!isRecord(copy)) throw new Error(`Plugin setting ${key} locale ${locale} must be an object.`);
|
||||
const localized: NonNullable<PluginSettingBase["locales"]>[string] = {};
|
||||
for (const property of ["label", "description", "placeholder"] as const) {
|
||||
if (copy[property] === undefined) continue;
|
||||
const limit = property === "description" ? 1000 : 200;
|
||||
if (typeof copy[property] !== "string" || !copy[property].trim() || copy[property].length > limit || (property === "placeholder" && type !== "text" && type !== "secret")) {
|
||||
throw new Error(`Plugin setting ${key} locale ${locale} has an invalid ${property}.`);
|
||||
}
|
||||
localized[property] = copy[property].trim();
|
||||
}
|
||||
if (copy.options !== undefined) {
|
||||
if (type !== "select" || !isRecord(copy.options) || Object.keys(copy.options).some((option) => !optionValues.includes(option))) {
|
||||
throw new Error(`Plugin setting ${key} locale ${locale} has invalid options.`);
|
||||
}
|
||||
const translatedOptions: Array<[string, string]> = [];
|
||||
for (const [option, label] of Object.entries(copy.options)) {
|
||||
if (typeof label !== "string" || !label.trim() || label.length > 200) throw new Error(`Plugin setting ${key} locale ${locale} has an invalid option label.`);
|
||||
translatedOptions.push([option, label.trim()]);
|
||||
}
|
||||
localized.options = Object.fromEntries(translatedOptions);
|
||||
}
|
||||
if (Object.keys(localized).length === 0) throw new Error(`Plugin setting ${key} locale ${locale} must include translated copy.`);
|
||||
locales[locale] = localized;
|
||||
}
|
||||
return locales;
|
||||
};
|
||||
|
||||
const normalizeSettingList = (field: Record<string, unknown>, key: string): PluginSettingList | undefined => {
|
||||
if (field.list === undefined) return undefined;
|
||||
if (!isRecord(field.list) || !Array.isArray(field.list.items) || field.list.items.length === 0 || field.list.items.length > 100) {
|
||||
@@ -803,9 +841,13 @@ const normalizePluginSettings = (value: unknown): PluginSettingsSchema => {
|
||||
if (typeof field.label !== "string" || !field.label.trim() || field.label.length > 200) throw new Error(`Plugin setting ${field.key} requires a label of at most 200 characters.`);
|
||||
if (typeof field.description === "string" && field.description.length > 1000) throw new Error(`Plugin setting ${field.key} description is too long.`);
|
||||
const list = normalizeSettingList(field, field.key);
|
||||
const optionValues = field.type === "select" && Array.isArray(field.options)
|
||||
? field.options.filter(isRecord).map((option) => String(option.value)) : [];
|
||||
const locales = normalizeSettingLocales(field.locales, field.key, String(field.type), optionValues);
|
||||
const common = {
|
||||
key: field.key,
|
||||
label: field.label.trim(),
|
||||
...(locales ? { locales } : {}),
|
||||
...(typeof field.description === "string" && field.description.trim() ? { description: field.description.trim() } : {}),
|
||||
...(field.required === true ? { required: true } : {}),
|
||||
...(list ? { list } : {}),
|
||||
|
||||
Reference in new issue
Block a user