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: "