feat(plugins): support localized settings fields and options

This commit is contained in:
tianma-if committed 2026-10-02 10:07:40 +08:00
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;
};
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 分发
+6
View File
@@ -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;
+53 -4
View File
@@ -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 } : {} };
}
+8 -1
View File
@@ -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");
});
});
+42
View File
@@ -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 } : {}),