From 4d159bca5e145bd8c1d8fd13dda81334d9614e7b Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 2 Oct 2026 10:07:40 +0800 Subject: [PATCH] feat(plugins): support localized settings fields and options --- .../plugins/PluginSettingsSection.tsx | 6 +- .../plugin-settings-localization.test.mjs | 27 +++++++++ .../plugins/plugin-settings-localization.ts | 20 +++++++ docs/plugin-development.md | 2 +- docs/plugin-development.zh-CN.md | 2 +- packages/plugin-api/dist/index.d.ts | 6 ++ packages/plugin-api/dist/index.js | 57 +++++++++++++++++-- packages/plugin-api/src/index.test.mjs | 9 ++- packages/plugin-api/src/index.ts | 42 ++++++++++++++ 9 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/plugins/plugin-settings-localization.test.mjs create mode 100644 apps/web/src/components/plugins/plugin-settings-localization.ts diff --git a/apps/web/src/components/plugins/PluginSettingsSection.tsx b/apps/web/src/components/plugins/PluginSettingsSection.tsx index 7ddad909..2d5558f7 100644 --- a/apps/web/src/components/plugins/PluginSettingsSection.tsx +++ b/apps/web/src/components/plugins/PluginSettingsSection.tsx @@ -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; diff --git a/apps/web/src/components/plugins/plugin-settings-localization.test.mjs b/apps/web/src/components/plugins/plugin-settings-localization.test.mjs new file mode 100644 index 00000000..07778cbd --- /dev/null +++ b/apps/web/src/components/plugins/plugin-settings-localization.test.mjs @@ -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"); + }); +}); diff --git a/apps/web/src/components/plugins/plugin-settings-localization.ts b/apps/web/src/components/plugins/plugin-settings-localization.ts new file mode 100644 index 00000000..1f2e06c5 --- /dev/null +++ b/apps/web/src/components/plugins/plugin-settings-localization.ts @@ -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; +}; diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 10894bb6..79efa810 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -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 diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md index 6af6da63..40010b20 100644 --- a/docs/plugin-development.zh-CN.md +++ b/docs/plugin-development.zh-CN.md @@ -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 分发 diff --git a/packages/plugin-api/dist/index.d.ts b/packages/plugin-api/dist/index.d.ts index f1e7757a..d47e9c6a 100644 --- a/packages/plugin-api/dist/index.d.ts +++ b/packages/plugin-api/dist/index.d.ts @@ -43,6 +43,12 @@ interface PluginSettingBase { key: string; label: string; description?: string; + locales?: Record; + }>; required?: boolean; /** Host-rendered read-only items, opened from a small entry next to the field. */ list?: PluginSettingList; diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js index f5799f27..d36caee2 100644 --- a/packages/plugin-api/dist/index.js +++ b/packages/plugin-api/dist/index.js @@ -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 } : {} }; } diff --git a/packages/plugin-api/src/index.test.mjs b/packages/plugin-api/src/index.test.mjs index b1f52f20..01ec8348 100644 --- a/packages/plugin-api/src/index.test.mjs +++ b/packages/plugin-api/src/index.test.mjs @@ -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"); }); }); diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 8e6b56f3..f315f2b5 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -74,6 +74,7 @@ interface PluginSettingBase { key: string; label: string; description?: string; + locales?: Record }>; 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 = {}; + 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[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, 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 } : {}),