feat: localize plugin marketplace metadata
This commit is contained in:
1 parent
c6ecc49164
commit
998ce01650
14 files changed
+290
-17
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = ({
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-950">{manifest.name}</h2>
|
||||
<h2 className="text-xl font-semibold text-slate-950">{name}</h2>
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">{manifest.type}</span>
|
||||
{update ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700">
|
||||
@@ -175,10 +182,10 @@ const PluginDetailView = ({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{manifest.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600">{manifest.description}</p> : null}
|
||||
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600">{description}</p> : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t("plugins.toggle", { name: manifest.name })}
|
||||
aria-label={t("plugins.toggle", { name })}
|
||||
checked={extension.enabled}
|
||||
disabled={pendingId === id}
|
||||
onCheckedChange={onToggle}
|
||||
@@ -468,6 +475,7 @@ export const PluginManagerCard = ({
|
||||
<PluginDetailView
|
||||
page={getPluginDetailPage(selectedExtension.manifest, requestedPage)}
|
||||
extension={selectedExtension}
|
||||
marketplaceEntry={catalogItems.find((item) => 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)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+10
@@ -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<string, LocalizedExtensionMetadata>;
|
||||
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;
|
||||
|
||||
Vendored
+55
-2
@@ -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(),
|
||||
|
||||
@@ -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: "<script>" },
|
||||
},
|
||||
entry: "./main.js",
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
expect(manifest.locales).toEqual({
|
||||
"zh-CN": { name: "本地化插件", description: "中文说明" },
|
||||
ja: { description: "日本語の説明" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid localized plugin metadata", () => {
|
||||
const base = {
|
||||
type: "plugin",
|
||||
id: "org.edgeever.localized",
|
||||
name: "Localized",
|
||||
version: "1.0.0",
|
||||
apiVersion: "2",
|
||||
settingsUi: "host",
|
||||
entry: "./main.js",
|
||||
permissions: [],
|
||||
};
|
||||
expect(() => parseExtensionManifest({ ...base, locales: { invalid_locale_tag: { description: "Bad" } } })).toThrow("BCP 47");
|
||||
expect(() => parseExtensionManifest({ ...base, locales: { "zh-CN": {} } })).toThrow("name or description");
|
||||
});
|
||||
|
||||
test("rejects unsupported capability metadata", () => {
|
||||
expect(() => parseExtensionManifest({
|
||||
type: "plugin",
|
||||
@@ -202,6 +240,7 @@ describe("marketplace registry", () => {
|
||||
id: "org.edgeever.example",
|
||||
name: "Example",
|
||||
description: "Example plugin",
|
||||
locales: { "zh-CN": { description: "示例插件" } },
|
||||
author: "EdgeEver",
|
||||
publisher: "edgeever",
|
||||
category: "Productivity",
|
||||
@@ -209,7 +248,12 @@ describe("marketplace registry", () => {
|
||||
distribution: { type: "github", repositoryUrl: "https://github.com/edgeever/example" },
|
||||
verification: { version: "1.0.0", checksums: { manifestJson: "a".repeat(64), mainJs: "b".repeat(64) } },
|
||||
}],
|
||||
}).entries[0]).toMatchObject({ id: "org.edgeever.example", publisher: "edgeever", verification: { version: "1.0.0" } });
|
||||
}).entries[0]).toMatchObject({
|
||||
id: "org.edgeever.example",
|
||||
publisher: "edgeever",
|
||||
locales: { "zh-CN": { description: "示例插件" } },
|
||||
verification: { version: "1.0.0" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects unknown automatic-update publishers", () => {
|
||||
|
||||
@@ -29,6 +29,13 @@ export const PLUGIN_PERMISSIONS = [
|
||||
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<string, LocalizedExtensionMetadata>;
|
||||
|
||||
export interface PluginManifest {
|
||||
type: "plugin";
|
||||
id: string;
|
||||
@@ -38,6 +45,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[];
|
||||
@@ -121,6 +130,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;
|
||||
@@ -135,6 +146,7 @@ export interface MarketplaceEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
locales?: ExtensionLocales;
|
||||
author: string;
|
||||
publisher?: "edgeever";
|
||||
category: string;
|
||||
@@ -696,6 +708,42 @@ const assertCommonManifest = (value: Record<string, unknown>) => {
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeExtensionLocales = (value: unknown, label: string): ExtensionLocales | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
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<string>();
|
||||
const locales: ExtensionLocales = {};
|
||||
for (const [rawLocale, metadata] of entries) {
|
||||
let locale: string;
|
||||
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;
|
||||
};
|
||||
|
||||
const normalizeThemeTokens = (value: unknown): ThemeTokens => {
|
||||
if (!isRecord(value)) throw new Error("Theme tokens must be an object.");
|
||||
const allowed = new Set<string>(THEME_TOKEN_NAMES);
|
||||
@@ -812,6 +860,7 @@ const normalizePluginSettings = (value: unknown): PluginSettingsSchema => {
|
||||
export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
|
||||
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)}`);
|
||||
@@ -838,7 +887,15 @@ export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
|
||||
? [...new Set(value.platforms.map(String))] as ExtensionPlatform[]
|
||||
: (() => { 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 } as PluginManifest;
|
||||
return {
|
||||
...value,
|
||||
type: "plugin",
|
||||
permissions,
|
||||
networkHosts,
|
||||
platforms,
|
||||
settings,
|
||||
...(locales ? { locales } : {}),
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
if (value.type === "theme") {
|
||||
@@ -852,6 +909,7 @@ export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
|
||||
modes: [...new Set(value.modes)] as Array<"light" | "dark">,
|
||||
light: normalizeThemeTokens(value.light),
|
||||
dark: value.dark === undefined ? undefined : normalizeThemeTokens(value.dark),
|
||||
...(locales ? { locales } : {}),
|
||||
} as ThemeManifest;
|
||||
}
|
||||
|
||||
@@ -882,6 +940,7 @@ export const parseMarketplaceRegistry = (value: unknown): MarketplaceRegistry =>
|
||||
const name = item.name as string;
|
||||
const description = item.description as string;
|
||||
const author = item.author as string;
|
||||
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.`);
|
||||
}
|
||||
@@ -914,6 +973,7 @@ export const parseMarketplaceRegistry = (value: unknown): MarketplaceRegistry =>
|
||||
id: item.id,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
...(locales ? { locales } : {}),
|
||||
author: author.trim(),
|
||||
...(item.publisher === "edgeever" ? { publisher: "edgeever" as const } : {}),
|
||||
category: category.trim(),
|
||||
|
||||
Reference in new issue
Block a user