Plugins need configuration—API keys, feature flags, display preferences. EmDash provides two mechanisms: a settings schema for admin-configurable options and a KV store for programmatic access.
Settings Schema
Declare a settings schema in admin.settingsSchema to auto-generate an admin UI:
import { definePlugin } from "emdash";
export default definePlugin({
id: "seo",
version: "1.0.0",
admin: {
settingsSchema: {
siteTitle: {
type: "string",
label: "Site Title",
description: "Used in title tags and meta",
default: "",
},
maxTitleLength: {
type: "number",
label: "Max Title Length",
description: "Characters before truncation",
default: 60,
min: 30,
max: 100,
},
generateSitemap: {
type: "boolean",
label: "Generate Sitemap",
description: "Automatically generate sitemap.xml",
default: true,
},
defaultRobots: {
type: "select",
label: "Default Robots",
options: [
{ value: "index,follow", label: "Index & Follow" },
{ value: "noindex,follow", label: "No Index, Follow" },
{ value: "noindex,nofollow", label: "No Index, No Follow" },
],
default: "index,follow",
},
apiKey: {
type: "secret",
label: "API Key",
description: "Encrypted at rest",
},
},
},
});
EmDash generates a settings form in the plugin’s admin section. Users edit settings without touching code.
Field Types
String
Text input for single-line or multiline strings.
siteTitle: {
type: "string",
label: "Site Title",
description: "Optional help text",
default: "My Site",
multiline: false // Set true for textarea
}
Number
Numeric input with optional min/max constraints.
maxItems: {
type: "number",
label: "Maximum Items",
default: 100,
min: 1,
max: 1000
}
Boolean
Toggle switch for true/false values.
enabled: {
type: "boolean",
label: "Enabled",
description: "Turn this feature on or off",
default: true
}
Select
Dropdown for predefined options.
theme: {
type: "select",
label: "Theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "auto", label: "System" }
],
default: "auto"
}
Secret
Encrypted field for sensitive values like API keys. Never sent to the client after saving.
apiKey: {
type: "secret",
label: "API Key",
description: "Stored encrypted"
}
Accessing Settings
Read settings in hooks and routes via ctx.kv:
"content:beforeSave": async (event, ctx) => {
// Read a setting
const maxLength = await ctx.kv.get<number>("settings:maxTitleLength");
const apiKey = await ctx.kv.get<string>("settings:apiKey");
// Use defaults if not set
const limit = maxLength ?? 60;
ctx.log.info("Using max length", { limit });
return event.content;
}
Settings are stored with the settings: prefix by convention. This distinguishes user-configurable values from internal plugin state.
KV Store API
The KV store (ctx.kv) is a general-purpose key-value store for plugin data:
interface KVAccess {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<boolean>;
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
}
Reading Values
// Get a single value
const enabled = await ctx.kv.get<boolean>("settings:enabled");
// Get with type
const config = await ctx.kv.get<{ url: string; timeout: number }>("state:config");
Writing Values
// Set a value
await ctx.kv.set("settings:lastSync", new Date().toISOString());
// Set complex values
await ctx.kv.set("state:cache", {
data: items,
expiry: Date.now() + 3600000,
});
Listing Values
// List all settings
const settings = await ctx.kv.list("settings:");
// Returns: [{ key: "settings:enabled", value: true }, ...]
// List all plugin keys
const all = await ctx.kv.list();
Deleting Values
const deleted = await ctx.kv.delete("state:tempData");
// Returns true if key existed
Key Naming Conventions
Use prefixes to organize KV data:
| Prefix | Purpose | Example |
|---|---|---|
settings: | User-configurable preferences | settings:apiKey |
state: | Internal plugin state | state:lastSync |
cache: | Cached data | cache:results |
// Good: clear prefixes
await ctx.kv.set("settings:webhookUrl", url);
await ctx.kv.set("state:lastRun", timestamp);
await ctx.kv.set("cache:feed", feedData);
// Avoid: no prefix, unclear purpose
await ctx.kv.set("url", url);
Settings vs Storage vs KV
Choose the right storage mechanism:
| Use Case | Mechanism |
|---|---|
| Admin-editable preferences | admin.settingsSchema + ctx.kv with settings: |
| Internal plugin state | ctx.kv with state: |
| Collections of documents | ctx.storage |
Settings are for user-configurable values—things an admin might change. They get an auto-generated UI.
KV is for internal state like timestamps, sync cursors, or cached computations. No UI, just code.
Storage is for document collections with indexed queries—form submissions, audit logs, etc.
Loading Settings in Routes
API routes can expose settings to admin UI components:
routes: {
settings: {
handler: async (ctx) => {
const settings = await ctx.kv.list("settings:");
const result: Record<string, unknown> = {};
for (const entry of settings) {
const key = entry.key.replace("settings:", "");
result[key] = entry.value;
}
return result;
}
},
"settings/save": {
handler: async (ctx) => {
const input = ctx.input as Record<string, unknown>;
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
await ctx.kv.set(`settings:${key}`, value);
}
}
return { success: true };
}
}
}
Default Values
Settings from settingsSchema are not automatically persisted. They’re defaults in the admin UI. Your code should handle missing values:
"content:afterSave": async (event, ctx) => {
// Always provide a fallback
const enabled = await ctx.kv.get<boolean>("settings:enabled") ?? true;
const maxItems = await ctx.kv.get<number>("settings:maxItems") ?? 100;
if (!enabled) return;
// ...
}
Alternatively, persist defaults in plugin:install:
hooks: {
"plugin:install": async (_event, ctx) => {
// Persist schema defaults
await ctx.kv.set("settings:enabled", true);
await ctx.kv.set("settings:maxItems", 100);
}
}
Storage Implementation
KV values are stored in the _options table with plugin-namespaced keys:
INSERT INTO _options (name, value) VALUES
('plugin:seo:settings:siteTitle', '"My Site"'),
('plugin:seo:settings:maxTitleLength', '60');
The plugin:seo: prefix is added automatically. Your code uses settings:siteTitle, and EmDash stores it as plugin:seo:settings:siteTitle.
This ensures plugins can’t accidentally overwrite each other’s data.