Este guia mostra como construir um plugin EmDash completo. Vai aprender a estruturar o código, definir hooks e armazenamento, e exportar componentes de UI de administração.
Estrutura do plugin
Cada plugin tem duas partes que correm em contextos diferentes:
- Descritor do plugin (
PluginDescriptor) — devolvido pela função de fábrica, indica ao EmDash como carregar o plugin. Corre no momento de build no Vite (importado emastro.config.mjs). Deve ser livre de efeitos secundários e não pode usar APIs de runtime. - Definição do plugin (
definePlugin()) — contém a lógica de runtime (hooks, rotas, armazenamento). Corre no momento do pedido no servidor implantado. Tem acesso ao contexto completo do plugin (ctx).
Devem estar em entrypoints separados porque correm em ambientes completamente diferentes:
my-plugin/
├── src/
│ ├── descriptor.ts # Descritor do plugin (corre no Vite no build)
│ ├── index.ts # Definição do plugin com definePlugin() (corre no deploy)
│ ├── admin.tsx # Exportações de UI admin (componentes React) — opcional
│ └── astro/ # Opcional: componentes Astro para renderização no site
│ └── index.ts # Deve exportar `blockComponents`
├── package.json
└── tsconfig.json
Criar o plugin
Descritor (build time)
O descritor indica ao EmDash onde encontrar o plugin e que UI de admin fornece. Este ficheiro é importado em astro.config.mjs e corre no Vite.
import type { PluginDescriptor } from "emdash";
// Opções que o plugin aceita no momento de registo
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" }],
};
}
Definição (runtime)
A definição contém a lógica de runtime — hooks, rotas, armazenamento e configuração de admin. Este ficheiro é carregado no momento do pedido no servidor implantado.
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",
// Declarar capacidades necessárias
capabilities: ["read:content"],
// Armazenamento do plugin (coleções de documentos)
storage: {
items: {
indexes: ["status", "createdAt", ["status", "createdAt"]],
},
},
// Configuração da UI de 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" }],
},
// Tratadores de hooks
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,
});
},
},
// Rotas de API (apenas confiável — indisponível em plugins em sandbox)
routes: {
status: {
handler: async (ctx) => {
const count = await ctx.storage.items!.count();
return { count, maxItems };
},
},
},
});
}
export default createPlugin;
Regras de ID do plugin
O campo id deve seguir estas regras:
- Apenas caracteres alfanuméricos minúsculos e hífens
- Simples (
my-plugin) ou com escopo (@my-org/my-plugin) - Único entre todos os plugins instalados
// IDs válidos
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// IDs inválidos
"MyPlugin"; // Sem maiúsculas
"my_plugin"; // Sem underscores
"my.plugin"; // Sem pontos
Formato de versão
Use versionamento semântico:
version: "1.0.0"; // Válido
version: "1.2.3-beta"; // Válido (pré-lançamento)
version: "1.0"; // Inválido (falta o patch)
Exportações do pacote
Configure as exportações no package.json para que o EmDash possa carregar cada entrypoint. O descritor e a definição são exportações separadas porque correm em ambientes diferentes:
{
"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"
}
}
| Exportação | Contexto | Finalidade |
|---|---|---|
"." | Servidor (runtime) | createPlugin() / definePlugin() — carregado pelo entrypoint no momento do pedido |
"./descriptor" | Vite (build time) | Fábrica de PluginDescriptor — importado em astro.config.mjs |
"./admin" | Browser | Componentes React para páginas/widgets de admin |
"./astro" | Servidor (SSR) | Componentes Astro para renderização de blocos no site |
Inclua apenas as exportações ./admin e ./astro se o plugin as utilizar.
Exemplo completo: Plugin de registo de auditoria
Este exemplo demonstra armazenamento, hooks de ciclo de vida, hooks de conteúdo e rotas de 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,
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;
Testar plugins
Teste plugins criando um site Astro mínimo com o plugin registado:
-
Crie um site de teste com o EmDash instalado.
-
Registe o plugin em
astro.config.mjs:import myPlugin from "../path/to/my-plugin/src"; export default defineConfig({ integrations: [ emdash({ plugins: [myPlugin()], }), ], }); -
Execute o servidor de desenvolvimento e acione hooks ao criar/atualizar conteúdo.
-
Verifique a consola para a saída de
ctx.loge confirme o armazenamento via rotas de API.
Para testes unitários, simule a interface PluginContext e chame os tratadores de hooks diretamente.
Tipos de bloco Portable Text
Os plugins podem adicionar tipos de bloco personalizados ao editor Portable Text. Aparecem no menu de comandos slash do editor e podem ser inseridos em qualquer campo portableText.
Declarar tipos de bloco
Em createPlugin(), declare blocos em admin.portableTextBlocks:
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // Ícone nomeado: video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // Campos Block Kit para a UI de edição
{ 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" },
],
},
],
}
Cada tipo de bloco define:
type— Nome do tipo de bloco (usado no_typedo Portable Text)label— Nome de exibição no menu de comandos slashicon— Chave de ícone (video,code,link,link-external). Recorre a um cubo genérico.placeholder— Texto de placeholder do campo de entradafields— Campos de formulário Block Kit para edição. Se omitido, mostra-se um campo URL simples.
Renderização no site
Para renderizar os tipos de bloco no site, exporte componentes Astro a partir de um componentsEntry:
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// Este nome de exportação é obrigatório — o módulo virtual importa-o
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
Defina componentsEntry no descritor do plugin:
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
// ...
};
}
Os componentes de bloco de plugins são automaticamente integrados no <PortableText> — os autores do site não precisam de importar nada. Componentes fornecidos pelo utilizador têm precedência sobre os padrões dos plugins.
Exportações do pacote
Adicione a exportação ./astro ao 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" }
}
}
Próximos passos
- Referência de hooks — Todos os hooks disponíveis com assinaturas
- API de armazenamento — Coleções de documentos e consultas
- Configurações — Esquema de configurações e armazenamento KV
- UI de admin — Páginas e widgets
- Rotas de API — Endpoints REST