設定

本頁內容

沙盒外掛程式將網站特定的設定儲存在其私有鍵值(KV)儲存中。Block Kit 管理頁面會載入目前的值、接受變更、驗證這些變更,並透過 ctx.kv 寫入。

讀寫 KV 值

每個鉤子和路由都會在 ctx 上接收此 KV 介面:

interface KVAccess {
	get<T>(key: string): Promise<T | null>;
	getVersioned<T>(key: string): Promise<{ value: T; revision: string } | null>;
	compareAndSet(key: string, expectedRevision: string | null, value: unknown):
		Promise<{ applied: true; revision: string } | { applied: false }>;
	compareAndDelete(key: string, expectedRevision: string): Promise<{ applied: boolean }>;
	set(key: string, value: unknown): Promise<void>;
	delete(key: string): Promise<boolean>;
	list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
}

KV 按外掛程式隔離。兩個外掛程式可以使用相同的鍵,而不會讀取或覆寫彼此的值。

當並行請求可能變更同一個鍵時,請使用條件寫入來拒絕基於過期修訂版本的更新。相同的方法在原生外掛程式和沙盒外掛程式中都可以使用。

使用前綴將使用者設定與內部狀態和快取值分開:

前綴用途範例
settings:使用者可設定的值settings:apiKey
state:持久化的內部狀態state:lastSync
cache:可重複使用的計算資料或遠端資料cache:feed

以下呼叫涵蓋了 KV 操作:

const enabled = await ctx.kv.get<boolean>("settings:enabled");
await ctx.kv.set("state:lastSync", new Date().toISOString());
const deleted = await ctx.kv.delete("cache:feed");
const allSettings = await ctx.kv.list("settings:");

當鍵不存在時,get 回傳 nulllist 回傳的鍵不包含 EmDash 的內部外掛程式命名空間前綴。

新增設定頁面

emdash-plugin.jsonc 中宣告頁面,使其出現在外掛程式的管理導覽中:

"admin": {
	"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
}

外掛程式還必須提供一個名為 admin 的私有路由。EmDash 在頁面開啟時傳送 page_load,在使用者提交表單時傳送 form_submit

新增 @emdash-cms/blockszod 以使用回應類型並驗證互動:

pnpm add @emdash-cms/blocks zod

以下路由載入三個值,並僅寫入經過驗證的表單欄位:

import type { BlockResponse } from "@emdash-cms/blocks";
import type { PluginContext, SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";

const interactionSchema = z.discriminatedUnion("type", [
	z.object({ type: z.literal("page_load"), page: z.string() }),
	z.object({
		type: z.literal("form_submit"),
		action_id: z.string(),
		block_id: z.string().optional(),
		values: z.object({
			apiKey: z.string().optional(),
			enabled: z.boolean(),
			maxItems: z.number().int().min(1).max(1000),
		}),
	}),
	z.object({
		type: z.literal("block_action"),
		action_id: z.string(),
		block_id: z.string().optional(),
		value: z.unknown().optional(),
	}),
]);

const plugin: SandboxedPlugin = {
	routes: {
		admin: {
			handler: async (routeCtx, ctx) => {
				const parsed = interactionSchema.safeParse(routeCtx.input);
				if (!parsed.success) return { blocks: [] };
				const interaction = parsed.data;

				if (interaction.type === "page_load" && interaction.page === "/settings") {
					return renderSettings(ctx);
				}

				if (interaction.type === "form_submit" && interaction.action_id === "save") {
					await saveSettings(ctx, interaction.values);
					return {
						...(await renderSettings(ctx)),
						toast: { message: "Settings saved", type: "success" },
					};
				}

				return { blocks: [] };
			},
		},
	},
};

export default plugin;

async function renderSettings(ctx: PluginContext): Promise<BlockResponse> {
	const apiKeyConfigured = (await ctx.kv.get<string>("settings:apiKey")) !== null;
	const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
	const maxItems = (await ctx.kv.get<number>("settings:maxItems")) ?? 100;

	return {
		blocks: [
			{ type: "header", text: "Plugin settings" },
			{
				type: "form",
				block_id: "settings",
				fields: [
					{
						type: "secret_input",
						action_id: "apiKey",
						label: "API key",
						has_value: apiKeyConfigured,
					},
					{
						type: "toggle",
						action_id: "enabled",
						label: "Enabled",
						initial_value: enabled,
					},
					{
						type: "number_input",
						action_id: "maxItems",
						label: "Max items",
						min: 1,
						max: 1000,
						initial_value: maxItems,
					},
				],
				submit: { label: "Save", action_id: "save" },
			},
		],
	};
}

async function saveSettings(
	ctx: PluginContext,
	values: { apiKey?: string; enabled: boolean; maxItems: number },
) {
	if (values.apiKey) await ctx.kv.set("settings:apiKey", values.apiKey);
	await ctx.kv.set("settings:enabled", values.enabled);
	await ctx.kv.set("settings:maxItems", values.maxItems);
}

提交的值會省略密鑰,直到使用者編輯它,如果使用者聚焦並清空欄位,則可能包含空字串。saveSettings 僅在提交的字串非空時才寫入新的 API 密鑰。頁面使用 has_value 來顯示已儲存的值存在,而不將值回傳給瀏覽器。

Block Kit 是互動、區塊、表單元素、建構器和條件欄位的權威參考。

密鑰值

僅當該儲存模型對於憑證是可接受的時才使用 KV。如果密鑰必須來自部署密鑰儲存且不得寫入 EmDash 資料庫,請使用原生外掛程式或外部憑證服務。沙盒外掛程式無法讀取主機程序的環境變數或平台繫結。

如果使用者需要清除密鑰,請提供一個單獨的、有意識的操作。將空的遮罩欄位視為刪除可能會在使用者儲存不相關的設定時擦除正在使用的憑證。

預設值和升級

在讀取鍵時套用預設值,這樣現有安裝無需遷移即可接收新的設定:

const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
const maxItems = (await ctx.kv.get<number>("settings:maxItems")) ?? 100;

你可以在安裝期間持久化初始值:

hooks: {
	"plugin:install": async (_event, ctx) => {
		await ctx.kv.set("settings:enabled", true);
		await ctx.kv.set("settings:maxItems", 100);
	},
},

plugin:install 僅在新安裝時執行。當後續版本新增了一個設定時,現有網站不會再次執行它。保留讀取時的回退值,或者在 plugin:activate 期間冪等地初始化缺失的鍵。

選擇 KV 還是 storage

資料用途
小型使用者可設定的值使用 ctx.kvsettings: 前綴
小型內部狀態或游標使用 ctx.kvstate: 前綴
可查詢的記錄,如提交或日誌宣告的 ctx.storage 集合
透過常規 EmDash 編輯器編輯的內容網站內容集合

KV 支援直接鍵存取和前綴列舉,但沒有欄位查詢或索引。Storage 提供帶有索引過濾、排序、計數和分頁的文件集合。

原生外掛程式可以改為在 definePlugin() 內宣告 admin.settingsSchema,讓 EmDash 產生表單。有關該格式,請參閱你的第一個原生外掛程式