diff --git a/apps/web/public/extensions/nord-emerald/manifest.json b/apps/web/public/extensions/nord-emerald/manifest.json index d4947de5..06fcf440 100644 --- a/apps/web/public/extensions/nord-emerald/manifest.json +++ b/apps/web/public/extensions/nord-emerald/manifest.json @@ -5,6 +5,12 @@ "version": "1.0.0", "themeApiVersion": "1", "description": "A calm Nord-inspired EdgeEver theme using the public theme token API.", + "locales": { + "zh-CN": { + "name": "Nord 翡翠", + "description": "一款宁静的 Nord 风格 EdgeEver 主题,基于公开主题令牌 API 构建。" + } + }, "author": "EdgeEver", "modes": ["light", "dark"], "light": { diff --git a/apps/web/public/extensions/recent-notes/manifest.json b/apps/web/public/extensions/recent-notes/manifest.json index c361a10b..da64e85c 100644 --- a/apps/web/public/extensions/recent-notes/manifest.json +++ b/apps/web/public/extensions/recent-notes/manifest.json @@ -6,6 +6,12 @@ "apiVersion": "2", "settingsUi": "host", "description": "Demonstrates note queries, editor selections, commands, notices, and a custom panel.", + "locales": { + "zh-CN": { + "name": "最近笔记示例", + "description": "演示笔记查询、编辑器选区、命令、通知和自定义面板。" + } + }, "author": "EdgeEver", "entry": "./main.js", "platforms": ["web", "desktop"], diff --git a/apps/web/public/extensions/registry.json b/apps/web/public/extensions/registry.json index ab8ef489..b32e91cb 100644 --- a/apps/web/public/extensions/registry.json +++ b/apps/web/public/extensions/registry.json @@ -1,11 +1,16 @@ { "registryVersion": "1", - "updatedAt": "2026-09-12T06:37:45.000Z", + "updatedAt": "2026-09-22T02:19:28.000Z", "entries": [ { "id": "org.edgeever.tasks", "name": "EdgeEver Tasks", "description": "Collects Markdown tasks across notes and links every task back to its source.", + "locales": { + "zh-CN": { + "description": "汇总所有笔记中的 Markdown 任务,并将每项任务链接回原笔记。" + } + }, "author": "EdgeEver", "publisher": "edgeever", "category": "Productivity", @@ -27,6 +32,11 @@ "id": "org.edgeever.plugins.ai-rss", "name": "EdgeEver AI RSS", "description": "AI-generated daily digests from curated RSS and Atom feeds, saved directly to EdgeEver notes.", + "locales": { + "zh-CN": { + "description": "根据精选的 RSS 和 Atom 订阅源生成 AI 每日摘要,并直接保存到 EdgeEver 笔记。" + } + }, "author": "EdgeEver", "publisher": "edgeever", "category": "News & AI", diff --git a/apps/web/src/components/plugins/PluginCatalogCard.tsx b/apps/web/src/components/plugins/PluginCatalogCard.tsx index fb902e05..e4221d30 100644 --- a/apps/web/src/components/plugins/PluginCatalogCard.tsx +++ b/apps/web/src/components/plugins/PluginCatalogCard.tsx @@ -47,10 +47,11 @@ export const PluginCatalogCard = ({ onRunCommand: (command: RegisteredPluginCommand) => void; onUninstall: () => void; }) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const extension = item.extension; - const name = getPluginCatalogName(item); - const description = getPluginCatalogDescription(item); + const locale = i18n.resolvedLanguage ?? i18n.language; + const name = getPluginCatalogName(item, locale); + const description = getPluginCatalogDescription(item, locale); const repositoryUrl = getPluginCatalogRepositoryUrl(item); const version = getPluginCatalogVersion(item); const sourceKey = getPluginCatalogSourceKey(item); diff --git a/apps/web/src/components/settings/PluginManagerCard.tsx b/apps/web/src/components/settings/PluginManagerCard.tsx index 6024fd3b..9d3113af 100644 --- a/apps/web/src/components/settings/PluginManagerCard.tsx +++ b/apps/web/src/components/settings/PluginManagerCard.tsx @@ -14,7 +14,8 @@ import { GitHubMark } from "@/components/GitHubRepositoryLink"; import { applyPluginUpdate, checkPluginUpdates, type PluginUpdateInfo } from "@/lib/plugins/plugin-updates"; import { PluginUpdateDialog } from "@/components/plugins/PluginUpdateDialog"; import { PluginSettingsSection } from "@/components/plugins/PluginSettingsSection"; -import { buildPluginCatalogItems, getPluginCatalogSourceKey } from "@/lib/plugins/plugin-catalog"; +import { buildPluginCatalogItems, getPluginCatalogDescription, getPluginCatalogName, getPluginCatalogSourceKey } from "@/lib/plugins/plugin-catalog"; +import type { MarketplaceEntry } from "@edgeever/plugin-api"; import { getPluginDetailPage, getPluginDetailPath, hasPluginSettings, isPluginCardCommand, type PluginDetailPage } from "@/lib/plugins/plugin-navigation"; import type { ScheduledTask } from "@edgeever/shared"; import { api, getOrCreateClientDeviceId } from "@/lib/api"; @@ -138,6 +139,7 @@ const PluginDetailView = ({ page, commands, extension, + marketplaceEntry, host, pendingId, update, @@ -149,6 +151,7 @@ const PluginDetailView = ({ page: PluginDetailPage; commands: RegisteredPluginCommand[]; extension: InstalledExtension; + marketplaceEntry?: MarketplaceEntry; host: EdgeEverPluginHost; pendingId: string | null; update?: PluginUpdateInfo; @@ -160,6 +163,10 @@ const PluginDetailView = ({ const { t, i18n } = useTranslation(); const { manifest } = extension; const id = manifest.id; + const catalogItem = { id, extension, marketplaceEntry }; + const locale = i18n.resolvedLanguage ?? i18n.language; + const name = getPluginCatalogName(catalogItem, locale); + const description = getPluginCatalogDescription(catalogItem, locale); const sourceKey = extension.source.verified ? "verified" : extension.source.kind; return ( @@ -167,7 +174,7 @@ const PluginDetailView = ({
-

{manifest.name}

+

{name}

{manifest.type} {update ? ( @@ -175,10 +182,10 @@ const PluginDetailView = ({ ) : null}
- {manifest.description ?

{manifest.description}

: null} + {description ?

{description}

: null}
item.id === selectedExtension.manifest.id)?.marketplaceEntry} host={host} update={updateQuery.data?.updates.find((update) => update.pluginId === selectedExtension.manifest.id)} commands={snapshot.commands.filter((command) => command.pluginId === selectedExtension.manifest.id)} diff --git a/apps/web/src/lib/plugins/plugin-catalog.test.mjs b/apps/web/src/lib/plugins/plugin-catalog.test.mjs index db948c5f..2970fce5 100644 --- a/apps/web/src/lib/plugins/plugin-catalog.test.mjs +++ b/apps/web/src/lib/plugins/plugin-catalog.test.mjs @@ -130,4 +130,28 @@ describe("plugin catalog items", () => { expect(getPluginCatalogVersion(item)).toBe("0.5.3"); expect(getPluginCatalogRepositoryUrl(item)).toBe("https://github.com/tianma-if/edgeever-ai-rss"); }); + + test("uses localized metadata from the installed manifest or marketplace with language fallback", () => { + const item = { + id: "org.edgeever.plugins.ai-rss", + marketplaceEntry: marketplaceEntry({ + locales: { + "zh-CN": { name: "EdgeEver AI 订阅", description: "市场中文说明" }, + ja: { description: "マーケット説明" }, + }, + }), + extension: extension({ + manifest: { + locales: { + zh: { description: "插件中文说明" }, + }, + }, + }), + }; + + expect(getPluginCatalogName(item, "zh-CN")).toBe("EdgeEver AI 订阅"); + expect(getPluginCatalogDescription(item, "zh-CN")).toBe("插件中文说明"); + expect(getPluginCatalogDescription(item, "ja-JP")).toBe("マーケット説明"); + expect(getPluginCatalogDescription(item, "fr-FR")).toBe("Installed RSS digest"); + }); }); diff --git a/apps/web/src/lib/plugins/plugin-catalog.ts b/apps/web/src/lib/plugins/plugin-catalog.ts index a7fda5c4..7eaac871 100644 --- a/apps/web/src/lib/plugins/plugin-catalog.ts +++ b/apps/web/src/lib/plugins/plugin-catalog.ts @@ -1,4 +1,4 @@ -import type { MarketplaceEntry } from "@edgeever/plugin-api"; +import type { ExtensionLocales, LocalizedExtensionMetadata, MarketplaceEntry } from "@edgeever/plugin-api"; import type { InstalledExtension } from "./plugin-host"; export interface PluginCatalogItem { @@ -34,11 +34,31 @@ export const getPluginCatalogSourceKey = (item: PluginCatalogItem): PluginCatalo return item.extension?.source.kind ?? null; }; -export const getPluginCatalogName = (item: PluginCatalogItem) => - item.extension?.manifest.name ?? item.marketplaceEntry?.name ?? item.id; +const normalizeLocale = (locale: string) => locale.trim().replaceAll("_", "-").toLocaleLowerCase(); -export const getPluginCatalogDescription = (item: PluginCatalogItem) => - item.extension?.manifest.description ?? item.marketplaceEntry?.description ?? ""; +const getLocalizedMetadata = (locales: ExtensionLocales | undefined, locale: string | undefined): LocalizedExtensionMetadata | undefined => { + if (!locales || !locale) return undefined; + const normalizedLocale = normalizeLocale(locale); + const entries = Object.entries(locales); + const exact = entries.find(([candidate]) => normalizeLocale(candidate) === normalizedLocale)?.[1]; + if (exact) return exact; + const language = normalizedLocale.split("-")[0]; + return entries.find(([candidate]) => normalizeLocale(candidate).split("-")[0] === language)?.[1]; +}; + +export const getPluginCatalogName = (item: PluginCatalogItem, locale?: string) => + getLocalizedMetadata(item.extension?.manifest.locales, locale)?.name + ?? getLocalizedMetadata(item.marketplaceEntry?.locales, locale)?.name + ?? item.extension?.manifest.name + ?? item.marketplaceEntry?.name + ?? item.id; + +export const getPluginCatalogDescription = (item: PluginCatalogItem, locale?: string) => + getLocalizedMetadata(item.extension?.manifest.locales, locale)?.description + ?? getLocalizedMetadata(item.marketplaceEntry?.locales, locale)?.description + ?? item.extension?.manifest.description + ?? item.marketplaceEntry?.description + ?? ""; export const getPluginCatalogRepositoryUrl = (item: PluginCatalogItem) => item.extension?.source.repositoryUrl ?? item.marketplaceEntry?.repositoryUrl; diff --git a/apps/web/src/lib/plugins/plugin-marketplace.test.mjs b/apps/web/src/lib/plugins/plugin-marketplace.test.mjs index cae2ff8c..b6cd2f68 100644 --- a/apps/web/src/lib/plugins/plugin-marketplace.test.mjs +++ b/apps/web/src/lib/plugins/plugin-marketplace.test.mjs @@ -26,6 +26,9 @@ describe("bundled plugin marketplace", () => { expect(registry.entries.map((entry) => entry.id)).not.toContain("org.edgeever.examples.recent-notes"); expect(registry.entries.map((entry) => entry.id)).not.toContain("org.edgeever.themes.nord-emerald"); for (const entry of registry.entries) { + if (entry.publisher === "edgeever") { + expect(entry.locales?.["zh-CN"]?.description).toBeTruthy(); + } if (entry.distribution.type !== "manifest") continue; const relativeManifestPath = entry.distribution.manifestUrl.replace(/^\/extensions\//, ""); const manifestFileUrl = new URL(`../../../public/extensions/${relativeManifestPath}`, import.meta.url); diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 72bfa954..e78b837f 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -23,6 +23,12 @@ Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bind "apiVersion": "2", "settingsUi": "host", "description": "Adds a command for recent notes.", + "locales": { + "zh-CN": { + "name": "最近笔记", + "description": "添加一个查看最近笔记的命令。" + } + }, "entry": "./main.js", "platforms": ["web", "desktop"], "permissions": ["notes:read", "editor:read", "ui:commands", "ui:notices", "ui:panels"] @@ -31,6 +37,8 @@ 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. + ## GitHub distribution Developers can share a public GitHub repository URL directly with users. The default branch must contain the latest `manifest.json` at its root, and every version is published as a GitHub Release. The Release tag may be the manifest version or its `v`-prefixed form, such as `1.2.0` or `v1.2.0`. @@ -73,6 +81,12 @@ Registry format: "id": "com.example.recent-notes", "name": "Recent Notes", "description": "Shows recently updated notes.", + "locales": { + "zh-CN": { + "name": "最近笔记", + "description": "显示最近更新的笔记。" + } + }, "author": "EdgeEver", "publisher": "edgeever", "category": "Productivity", diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md index fae3fcdd..e2a92f0f 100644 --- a/docs/plugin-development.zh-CN.md +++ b/docs/plugin-development.zh-CN.md @@ -23,6 +23,12 @@ EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。 "apiVersion": "2", "settingsUi": "host", "description": "Adds a command for recent notes.", + "locales": { + "zh-CN": { + "name": "最近笔记", + "description": "添加一个查看最近笔记的命令。" + } + }, "entry": "./main.js", "platforms": ["web", "desktop"], "permissions": ["notes:read", "editor:read", "ui:commands", "ui:notices", "ui:panels"] @@ -31,6 +37,8 @@ EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。 Manifest 和 JavaScript 模块必须返回允许 EdgeEver 来源访问的 CORS 响应头。相对 `entry` 地址基于 Manifest 地址解析。 +顶层必填的 `name` 与可选的 `description` 保持为回退文案。插件与主题可以增加以 BCP 47 语言标签为键的 `locales` 对象,例如 `zh-CN`、`en-US` 或 `ja`;每种语言可覆盖 `name`、`description` 或两者。EdgeEver 会先匹配当前界面语言,再匹配相同基础语言,最后回退到顶层字段。这里本地化的是插件市场与插件管理页的元数据;运行时命令、面板、通知及宿主渲染的设置项标签仍由插件自行负责本地化。 + ## 通过 GitHub 分发 开发者可以把公开 GitHub 仓库地址直接分享给用户。仓库默认分支根目录必须包含最新的 `manifest.json`,每个版本通过 GitHub Release 发布。Release Tag 使用 Manifest 中的版本号或带 `v` 前缀的版本号,例如 `1.2.0` 或 `v1.2.0`。 @@ -73,6 +81,12 @@ Registry 格式: "id": "com.example.recent-notes", "name": "Recent Notes", "description": "Shows recently updated notes.", + "locales": { + "zh-CN": { + "name": "最近笔记", + "description": "显示最近更新的笔记。" + } + }, "author": "EdgeEver", "publisher": "edgeever", "category": "Productivity", diff --git a/packages/plugin-api/dist/index.d.ts b/packages/plugin-api/dist/index.d.ts index d0f23bb1..f1e7757a 100644 --- a/packages/plugin-api/dist/index.d.ts +++ b/packages/plugin-api/dist/index.d.ts @@ -3,6 +3,11 @@ export declare const THEME_API_VERSION: "1"; export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "network:public", "ai:generate", "storage", "secrets", "schedules", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels", "ui:embeds"]; export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number]; export type ExtensionPlatform = "web" | "desktop" | "android" | "ios"; +export interface LocalizedExtensionMetadata { + name?: string; + description?: string; +} +export type ExtensionLocales = Record; export interface PluginManifest { type: "plugin"; id: string; @@ -12,6 +17,8 @@ export interface PluginManifest { /** Plugins must delegate ordinary persistent configuration to EdgeEver. */ settingsUi: "host"; description?: string; + /** Localized marketplace and manager copy keyed by a BCP 47 language tag. */ + locales?: ExtensionLocales; author?: string; entry: string; platforms?: ExtensionPlatform[]; @@ -84,6 +91,8 @@ export interface ThemeManifest { version: string; themeApiVersion: typeof THEME_API_VERSION; description?: string; + /** Localized marketplace and manager copy keyed by a BCP 47 language tag. */ + locales?: ExtensionLocales; author?: string; modes: Array<"light" | "dark">; light: ThemeTokens; @@ -95,6 +104,7 @@ export interface MarketplaceEntry { id: string; name: string; description: string; + locales?: ExtensionLocales; author: string; publisher?: "edgeever"; category: string; diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js index ec4e47a6..f5799f27 100644 --- a/packages/plugin-api/dist/index.js +++ b/packages/plugin-api/dist/index.js @@ -189,6 +189,47 @@ var assertCommonManifest = (value) => { throw new Error("Extension version must use SemVer."); } }; +var normalizeExtensionLocales = (value, label) => { + if (value === undefined) + return; + if (!isRecord(value)) + throw new Error(`${label} locales must be an object.`); + const entries = Object.entries(value); + if (entries.length > 50) + throw new Error(`${label} cannot declare more than 50 locales.`); + const normalizedKeys = new Set; + const locales = {}; + for (const [rawLocale, metadata] of entries) { + let locale; + try { + locale = Intl.getCanonicalLocales(rawLocale.trim().replaceAll("_", "-"))[0] ?? ""; + } catch { + locale = ""; + } + if (!locale) + throw new Error(`${label} locale ${rawLocale} must be a BCP 47 language tag.`); + const normalizedKey = locale.toLocaleLowerCase(); + if (normalizedKeys.has(normalizedKey)) + throw new Error(`${label} contains a duplicate locale: ${locale}.`); + normalizedKeys.add(normalizedKey); + if (!isRecord(metadata)) + throw new Error(`${label} locale ${locale} must be an object.`); + if (metadata.name !== undefined && (typeof metadata.name !== "string" || !metadata.name.trim() || metadata.name.length > 200)) { + throw new Error(`${label} locale ${locale} name must be between 1 and 200 characters.`); + } + if (metadata.description !== undefined && (typeof metadata.description !== "string" || !metadata.description.trim() || metadata.description.length > 2000)) { + throw new Error(`${label} locale ${locale} description must be between 1 and 2000 characters.`); + } + if (metadata.name === undefined && metadata.description === undefined) { + throw new Error(`${label} locale ${locale} must declare a name or description.`); + } + locales[locale] = { + ...typeof metadata.name === "string" ? { name: metadata.name.trim() } : {}, + ...typeof metadata.description === "string" ? { description: metadata.description.trim() } : {} + }; + } + return locales; +}; var normalizeThemeTokens = (value) => { if (!isRecord(value)) throw new Error("Theme tokens must be an object."); @@ -320,6 +361,7 @@ var parseExtensionManifest = (value) => { if (!isRecord(value)) throw new Error("Extension manifest must be an object."); assertCommonManifest(value); + const locales = normalizeExtensionLocales(value.locales, `Extension ${String(value.id)}`); if (value.type === "plugin") { if (value.apiVersion !== PLUGIN_API_VERSION) throw new Error(`Unsupported plugin API version: ${String(value.apiVersion)}`); @@ -345,7 +387,15 @@ var parseExtensionManifest = (value) => { throw new Error("Plugin platforms contains an unsupported platform."); })(); const settings = value.settings === undefined ? undefined : normalizePluginSettings(value.settings); - return { ...value, type: "plugin", permissions, networkHosts, platforms, settings }; + return { + ...value, + type: "plugin", + permissions, + networkHosts, + platforms, + settings, + ...locales ? { locales } : {} + }; } if (value.type === "theme") { if (value.themeApiVersion !== THEME_API_VERSION) @@ -358,7 +408,8 @@ var parseExtensionManifest = (value) => { type: "theme", modes: [...new Set(value.modes)], light: normalizeThemeTokens(value.light), - dark: value.dark === undefined ? undefined : normalizeThemeTokens(value.dark) + dark: value.dark === undefined ? undefined : normalizeThemeTokens(value.dark), + ...locales ? { locales } : {} }; } throw new Error("Extension type must be plugin or theme."); @@ -390,6 +441,7 @@ var parseMarketplaceRegistry = (value) => { const name = item.name; const description = item.description; const author = item.author; + const locales = normalizeExtensionLocales(item.locales, `Marketplace entry ${item.id}`); if (item.publisher !== undefined && item.publisher !== "edgeever") { throw new Error(`Marketplace entry ${item.id} has an invalid publisher.`); } @@ -421,6 +473,7 @@ var parseMarketplaceRegistry = (value) => { id: item.id, name: name.trim(), description: description.trim(), + ...locales ? { locales } : {}, author: author.trim(), ...item.publisher === "edgeever" ? { publisher: "edgeever" } : {}, category: category.trim(), diff --git a/packages/plugin-api/src/index.test.mjs b/packages/plugin-api/src/index.test.mjs index 4ff56054..b1f52f20 100644 --- a/packages/plugin-api/src/index.test.mjs +++ b/packages/plugin-api/src/index.test.mjs @@ -15,6 +15,44 @@ describe("extension manifests", () => { })).toMatchObject({ permissions: ["notes:read", "templates:read", "templates:write", "schedules", "ui:commands", "ui:navigation", "ui:embeds"] }); }); + test("normalizes localized plugin metadata", () => { + const manifest = parseExtensionManifest({ + type: "plugin", + id: "org.edgeever.localized", + name: "Localized", + version: "1.0.0", + apiVersion: "2", + settingsUi: "host", + description: "English description", + locales: { + zh_CN: { name: "本地化插件", description: " 中文说明 " }, + ja: { description: "日本語の説明", html: "