Este guia orienta você na construção de um plugin nativo do zero. Plugins nativos são executados no mesmo processo que seu site Astro — sem limite de sandbox, acesso completo ao runtime e acesso a recursos que o sandbox não pode fornecer (páginas de administração React, componentes Portable Text, fragmentos de página).
Se você ainda não decidiu se quer um plugin nativo em vez de um em sandbox, leia primeiro Escolhendo um formato de plugin. O caminho nativo é para os casos em que o sandbox realmente não pode fazer o que você precisa.
Duas partes, em um ou dois arquivos
Como plugins em sandbox, plugins nativos incluem duas partes:
- Uma fábrica de descriptor — retorna um
PluginDescriptorcomformat: "native"mais pontos de entrada relacionados à administração. Importado porastro.config.mjsno momento da compilação. - Uma função
createPlugin(options)— o lado do runtime. Retorna um resultadodefinePlugin({ id, version, capabilities, hooks, routes, admin }).
Ao contrário dos plugins em sandbox, ambas as partes podem viver no mesmo arquivo porque não são executadas em ambientes diferentes — o plugin inteiro é executado em processo. A exportação "." do pacote aponta para um arquivo que exporta tanto a fábrica de descriptor quanto uma função createPlugin (ou default):
my-native-plugin/
├── src/
│ ├── index.ts # Fábrica de descriptor + createPlugin
│ ├── admin.tsx # Componentes de administração React (opcional)
│ └── astro/ # Componentes Astro para renderização de blocos PT (opcional)
│ └── index.ts
├── package.json
└── tsconfig.json
Configurar o pacote
{
"name": "@my-org/plugin-analytics",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./admin": {
"types": "./dist/admin.d.ts",
"import": "./dist/admin.js"
}
},
"files": ["dist"],
"peerDependencies": {
"emdash": "*",
"react": "^18.0.0"
}
}
Mantenha emdash e react como peer dependencies para que o site host forneça as versões reais e você não envie duplicatas.
Escrever o descriptor e o runtime
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
export interface AnalyticsOptions {
enabled?: boolean;
maxEvents?: number;
}
export function analyticsPlugin(options: AnalyticsOptions = {}): PluginDescriptor {
return {
id: "analytics",
version: "0.1.0",
format: "native",
entrypoint: "@my-org/plugin-analytics",
options,
adminEntry: "@my-org/plugin-analytics/admin",
adminPages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
adminWidgets: [{ id: "events-today", title: "Events Today", size: "third" }],
};
}
export function createPlugin(options: AnalyticsOptions = {}) {
const maxEvents = options.maxEvents ?? 100;
return definePlugin({
id: "analytics",
version: "0.1.0",
capabilities: ["network:request"],
allowedHosts: ["api.analytics.example.com"],
storage: {
events: { indexes: ["type", "createdAt"] },
},
admin: {
entry: "@my-org/plugin-analytics/admin",
settingsSchema: {
trackingId: { type: "string", label: "ID de rastreamento" },
enabled: { type: "boolean", label: "Habilitado", default: options.enabled ?? true },
},
pages: [{ path: "/dashboard", label: "Painel", icon: "chart" }],
widgets: [{ id: "events-today", title: "Eventos hoje", size: "third" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Plugin de analytics instalado", { maxEvents });
},
"content:afterSave": async (event, ctx) => {
const enabled = await ctx.kv.get<boolean>("settings:enabled");
if (enabled === false) return;
await ctx.storage.events.put(`evt_${Date.now()}`, {
type: "content:save",
contentId: event.content.id,
createdAt: new Date().toISOString(),
});
},
},
routes: {
stats: {
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
const count = await ctx.storage.events.count({
createdAt: { gte: today },
});
return { today: count };
},
},
},
});
}
export default createPlugin;
Alguns detalhes que vale a pena saber:
format: "native"é obrigatório. O padrão também seria"native"— mas ser explícito em cada descriptor facilita identificar com qual formato você está trabalhando.entrypointé a exportação principal do pacote. EmDash o importa em tempo de execução e chama a exportação padrão para construir o plugin resolvido.optionsflui do descriptor paracreatePlugin. Tudo o que o usuário passa ao registrar o plugin (analyticsPlugin({ enabled: false })) é preservado no descriptor e encaminhado paracreatePlugin. Plugins em sandbox não têm essa superfície — eles leem configurações do KV em vez disso.id,versionecapabilitiesaparecem duas vezes. Uma vez no descriptor, uma vez emdefinePlugin(). Eles devem corresponder. A cópia do descriptor é o queastro.config.mjsvê no momento da compilação; a cópia dedefinePlugin()é o que é executado no momento da solicitação.- Manipuladores de rota nativos recebem um único argumento —
(ctx: RouteContext)ondectx.input,ctx.requestectx.requestMetasão mesclados com as propriedades regulares doPluginContext. Isso é o oposto da forma de dois argumentos do formato padrão. Veja Rotas de API para a superfície completa (todo o resto é idêntico).
Regras de id do plugin
O campo id deve corresponder a /^[a-z][a-z0-9_-]*$/ — começar com uma letra minúscula, depois letras, dígitos, hífens ou underscores. O id é usado como um único segmento de caminho em URLs de rota de plugin e como parte de identificadores SQL gerados para índices de armazenamento de plugin, então qualquer coisa fora desse padrão falha em tempo de execução.
// Válido
"seo";
"audit-log";
"audit_log";
"plugin-forms";
// Inválido
"@my-org/plugin-forms"; // forma com escopo não permitida em tempo de execução
"MyPlugin"; // sem maiúsculas
"42-plugin"; // não pode começar com um dígito
"my.plugin"; // sem pontos
Combine um id sem escopo com um nome de pacote npm com escopo em entrypoint — o nome do pacote e o id do plugin são preocupações separadas.
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 (patch ausente)
Registrar o plugin
No astro.config.mjs do seu site, importe a fábrica de descriptor e passe-a para o array plugins: [] — plugins nativos sempre são executados em processo, nunca em sandboxed: []:
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { analyticsPlugin } from "@my-org/plugin-analytics";
export default defineConfig({
integrations: [
emdash({
plugins: [
analyticsPlugin({ enabled: true, maxEvents: 500 }),
],
}),
],
});
Interface de configurações
Plugins nativos podem usar admin.settingsSchema para um formulário de configurações gerado automaticamente, que é o caminho mais simples:
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "Chave API" },
enabled: { type: "boolean", label: "Habilitado", default: true },
maxItems: { type: "number", label: "Máx. itens", min: 1, max: 1000, default: 100 },
},
},
Tipos de campo: string, number, boolean, select, secret, url, email. Cada um aceita label, description, default, mais extras específicos do tipo como min/max/options. As configurações são persistidas no mesmo armazenamento KV por plugin que os plugins em sandbox usam — leia-os com ctx.kv.get<T>("settings:<key>") de qualquer lugar.
Para uma interface de configurações mais rica do que settingsSchema fornece, envie páginas React personalizadas — veja Páginas e widgets de administração React.
Exemplo completo: plugin de log de auditoria
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete";
collection: string;
resourceId: string;
userId?: string;
}
export function auditLogPlugin(): PluginDescriptor {
return {
id: "audit-log",
version: "0.1.0",
format: "native",
entrypoint: "@emdash-cms/plugin-audit-log",
};
}
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: "Retenção (dias)",
description: "Dias para manter entradas. 0 = para sempre.",
default: 90,
min: 0,
max: 365,
},
},
pages: [{ path: "/history", label: "Histórico de auditoria", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Atividade recente", size: "half" }],
},
hooks: {
"content:afterSave": {
priority: 200,
handler: async (event, ctx) => {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: event.isNew ? "create" : "update",
collection: event.collection,
resourceId: event.content.id as string,
};
await ctx.storage.entries.put(`${Date.now()}-${event.content.id}`, entry);
},
},
"content:afterDelete": {
priority: 200,
handler: async (event, ctx) => {
await ctx.storage.entries.put(`${Date.now()}-${event.id}`, {
timestamp: new Date().toISOString(),
action: "delete",
collection: event.collection,
resourceId: event.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),
})),
};
},
},
},
});
}
export default createPlugin;
Testes
Teste um plugin nativo criando um site Astro mínimo com o plugin registrado:
- Crie um site de teste com EmDash instalado.
- Registre seu plugin em
astro.config.mjs, importando-o diretamente do seu caminho de origem local. - Execute o servidor de desenvolvimento e acione hooks criando, atualizando ou excluindo conteúdo.
- Verifique o console para a saída de
ctx.loge verifique o armazenamento por meio de rotas de API.
Para testes de unidade, simule a interface PluginContext e chame os manipuladores de hooks diretamente.
Próximos passos
- Páginas e widgets de administração React — envie UI React personalizada para o painel de administração.
- Componentes de renderização Portable Text — forneça componentes Astro que renderizam tipos de bloco definidos pelo plugin.
- Fragmentos de página — injete scripts, folhas de estilo ou HTML em páginas públicas.
- Distribuição de plugins nativos — empacotamento npm e versionamento.