Criar plugins

Nesta página

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:

  1. 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 em astro.config.mjs). Deve ser livre de efeitos secundários e não pode usar APIs de runtime.
  2. 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çãoContextoFinalidade
"."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"BrowserComponentes 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:

  1. Crie um site de teste com o EmDash instalado.

  2. Registe o plugin em astro.config.mjs:

    import myPlugin from "../path/to/my-plugin/src";
    
    export default defineConfig({
    	integrations: [
    		emdash({
    			plugins: [myPlugin()],
    		}),
    	],
    });
  3. Execute o servidor de desenvolvimento e acione hooks ao criar/atualizar conteúdo.

  4. Verifique a consola para a saída de ctx.log e 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 _type do Portable Text)
  • label — Nome de exibição no menu de comandos slash
  • icon — Chave de ícone (video, code, link, link-external). Recorre a um cubo genérico.
  • placeholder — Texto de placeholder do campo de entrada
  • fields — 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