Questa guida illustra come costruire un plugin EmDash completo. Imparerai a strutturare il codice, definire hook e storage, ed esportare componenti per l’interfaccia admin.
Struttura del plugin
Ogni plugin ha due parti che vengono eseguite in contesti diversi:
- Descrittore del plugin (
PluginDescriptor) — restituito dalla funzione factory, indica a EmDash come caricare il plugin. Viene eseguito al build time in Vite (importato inastro.config.mjs). Deve essere privo di effetti collaterali e non può usare API runtime. - Definizione del plugin (
definePlugin()) — contiene la logica runtime (hook, route, storage). Viene eseguito al tempo della richiesta sul server distribuito. Ha accesso al contesto completo del plugin (ctx).
Devono trovarsi in entrypoint separati perché vengono eseguiti in ambienti completamente diversi:
my-plugin/
├── src/
│ ├── descriptor.ts # Descrittore del plugin (eseguito in Vite al build time)
│ ├── index.ts # Definizione del plugin con definePlugin() (eseguita al deploy time)
│ ├── admin.tsx # Export per UI admin (componenti React) — opzionale
│ └── astro/ # Opzionale: componenti Astro per il rendering lato sito
│ └── index.ts # Deve esportare `blockComponents`
├── package.json
└── tsconfig.json
Creare il plugin
Descrittore (build time)
Il descrittore indica a EmDash dove trovare il plugin e quale UI admin fornisce. Questo file viene importato in astro.config.mjs e viene eseguito in Vite.
import type { PluginDescriptor } from "emdash";
// Opzioni accettate dal plugin al momento della registrazione
export interface MyPluginOptions {
enabled?: boolean;
maxItems?: number;
}
export function myPlugin(options: MyPluginOptions = {}): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
entrypoint: "@my-org/plugin-example",
options,
adminEntry: "@my-org/plugin-example/admin",
componentsEntry: "@my-org/plugin-example/astro",
adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }],
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
};
}
Definizione (runtime)
La definizione contiene la logica runtime — hook, route, storage e configurazione admin. Questo file viene caricato al tempo della richiesta sul server distribuito.
import { definePlugin } from "emdash";
import type { MyPluginOptions } from "./descriptor.js";
export function createPlugin(options: MyPluginOptions = {}) {
const maxItems = options.maxItems ?? 100;
return definePlugin({
id: "my-plugin",
version: "1.0.0",
// Dichiara le capability richieste
capabilities: ["read:content"],
// Storage del plugin (raccolte di documenti)
storage: {
items: {
indexes: ["status", "createdAt", ["status", "createdAt"]],
},
},
// Configurazione UI admin
admin: {
entry: "@my-org/plugin-example/admin",
settingsSchema: {
maxItems: {
type: "number",
label: "Maximum Items",
description: "Limit stored items",
default: maxItems,
min: 1,
max: 1000,
},
enabled: {
type: "boolean",
label: "Enabled",
default: options.enabled ?? true,
},
},
pages: [{ path: "/settings", label: "Settings", icon: "settings" }],
widgets: [{ id: "status", title: "Status", size: "half" }],
},
// Gestori degli hook
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Plugin installed");
},
"content:afterSave": async (event, ctx) => {
const enabled = await ctx.kv.get<boolean>("settings:enabled");
if (enabled === false) return;
ctx.log.info("Content saved", {
collection: event.collection,
id: event.content.id,
});
},
},
// Route API (solo trusted — non disponibili nei plugin sandbox)
routes: {
status: {
handler: async (ctx) => {
const count = await ctx.storage.items!.count();
return { count, maxItems };
},
},
},
});
}
export default createPlugin;
Regole per l’ID del plugin
Il campo id deve seguire queste regole:
- Solo caratteri alfanumerici minuscoli e trattini
- Semplice (
my-plugin) o con scope (@my-org/my-plugin) - Unico tra tutti i plugin installati
// ID validi
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// ID non validi
"MyPlugin"; // Niente maiuscole
"my_plugin"; // Niente underscore
"my.plugin"; // Niente punti
Formato della versione
Usa il versionamento semantico:
version: "1.0.0"; // Valido
version: "1.2.3-beta"; // Valido (prerelease)
version: "1.0"; // Non valido (manca la patch)
Export del pacchetto
Configura gli export di package.json affinché EmDash possa caricare ogni entrypoint. Descrittore e definizione sono export separati perché vengono eseguiti in ambienti diversi:
{
"name": "@my-org/plugin-example",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./descriptor": {
"types": "./dist/descriptor.d.ts",
"import": "./dist/descriptor.js"
},
"./admin": {
"types": "./dist/admin.d.ts",
"import": "./dist/admin.js"
},
"./astro": {
"types": "./dist/astro/index.d.ts",
"import": "./dist/astro/index.js"
}
},
"files": ["dist"],
"peerDependencies": {
"emdash": "^0.1.0",
"react": "^18.0.0"
}
}
| Export | Contesto | Scopo |
|---|---|---|
"." | Server (runtime) | createPlugin() / definePlugin() — caricato da entrypoint al tempo della richiesta |
"./descriptor" | Vite (build time) | Factory PluginDescriptor — importato in astro.config.mjs |
"./admin" | Browser | Componenti React per pagine/widget admin |
"./astro" | Server (SSR) | Componenti Astro per il rendering lato sito dei blocchi |
Includi gli export ./admin e ./astro solo se il plugin li usa.
Esempio completo: plugin Audit Log
Questo esempio dimostra storage, hook del ciclo di vita, hook dei contenuti e route API:
import { definePlugin } from "emdash";
interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete";
collection: string;
resourceId: string;
userId?: string;
}
export function createPlugin() {
return definePlugin({
id: "audit-log",
version: "0.1.0",
storage: {
entries: {
indexes: [
"timestamp",
"action",
"collection",
["collection", "timestamp"],
["action", "timestamp"],
],
},
},
admin: {
settingsSchema: {
retentionDays: {
type: "number",
label: "Retention (days)",
description: "Days to keep entries. 0 = forever.",
default: 90,
min: 0,
max: 365,
},
},
pages: [{ path: "/history", label: "Audit History", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Audit log plugin installed");
},
"content:afterSave": {
priority: 200, // Eseguito dopo gli altri plugin
timeout: 2000,
handler: async (event, ctx) => {
const { content, collection, isNew } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: isNew ? "create" : "update",
collection,
resourceId: content.id as string,
};
const entryId = `${Date.now()}-${content.id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged ${entry.action} on ${collection}/${content.id}`);
},
},
"content:afterDelete": {
priority: 200,
timeout: 1000,
handler: async (event, ctx) => {
const { id, collection } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: "delete",
collection,
resourceId: id,
};
const entryId = `${Date.now()}-${id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged delete on ${collection}/${id}`);
},
},
},
routes: {
recent: {
handler: async (ctx) => {
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit: 10,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
};
},
},
history: {
handler: async (ctx) => {
const url = new URL(ctx.request.url);
const limit = parseInt(url.searchParams.get("limit") || "50", 10);
const cursor = url.searchParams.get("cursor") || undefined;
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit,
cursor,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
},
});
}
export default createPlugin;
Testare i plugin
Testa i plugin creando un sito Astro minimale con il plugin registrato:
-
Crea un sito di test con EmDash installato.
-
Registra il plugin in
astro.config.mjs:import myPlugin from "../path/to/my-plugin/src"; export default defineConfig({ integrations: [ emdash({ plugins: [myPlugin()], }), ], }); -
Avvia il server di sviluppo e attiva gli hook creando/aggiornando contenuti.
-
Controlla la console per l’output di
ctx.loge verifica lo storage tramite le route API.
Per i test unitari, crea un mock dell’interfaccia PluginContext e chiama direttamente i gestori degli hook.
Tipi di blocco Portable Text
I plugin possono aggiungere tipi di blocco personalizzati all’editor Portable Text. Compaiono nel menu dei comandi slash dell’editor e possono essere inseriti in qualsiasi campo portableText.
Dichiarare i tipi di blocco
In createPlugin(), dichiara i blocchi sotto admin.portableTextBlocks:
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // Icona con nome: video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // Campi Block Kit per l'UI di editing
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
],
},
],
}
Ogni tipo di blocco definisce:
type— Nome del tipo di blocco (usato in_typedi Portable Text)label— Nome visualizzato nel menu dei comandi slashicon— Chiave icona (video,code,link,link-external). Fallback a un cubo generico.placeholder— Testo segnaposto dell’inputfields— Campi del modulo Block Kit per l’editing. Se omesso, viene mostrato un semplice input URL.
Rendering lato sito
Per renderizzare i tipi di blocco sul sito, esporta componenti Astro da un componentsEntry:
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// Questo nome di export è obbligatorio — il modulo virtuale lo importa
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
Imposta componentsEntry nel descrittore del plugin:
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
// ...
};
}
I componenti di blocco dei plugin vengono automaticamente uniti in <PortableText> — gli autori del sito non devono importare nulla. I componenti forniti dall’utente hanno la precedenza su quelli predefiniti dei plugin.
Export del pacchetto
Aggiungi l’export ./astro a package.json:
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
"./astro": { "types": "./dist/astro/index.d.ts", "import": "./dist/astro/index.js" }
}
}
Passi successivi
- Riferimento hook — Tutti gli hook disponibili con le firme
- API Storage — Raccolte di documenti e query
- Impostazioni — Schema delle impostazioni e store KV
- UI Admin — Pagine e widget
- Route API — Endpoint REST