feat: localize plugin marketplace metadata

This commit is contained in:
tianma-if committed 2026-09-22 10:27:32 +08:00
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"],
+11 -1
View File
@@ -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");
});
});
+25 -5
View File
@@ -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);
+14
View File
@@ -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",
+14
View File
@@ -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",
+10
View File
@@ -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;
+55 -2
View File
@@ -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(),
+45 -1
View File
@@ -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", () => {
+61 -1
View File
@@ -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(),