Este guia orienta na construção de um plugin nativo do zero. Plugins nativos executam no mesmo processo que seu site Astro com acesso completo ao runtime, incluindo páginas admin React, componentes Portable Text e fragmentos de página.
Se você ainda não decidiu se quer um plugin nativo em vez de um sandbox, leia primeiro Escolhendo um formato de plugin. Nativo é o formato para plugins que precisam de páginas admin React, componentes de renderização Portable Text ou fragmentos de página.
Duas peças, em um ou dois arquivos
Como plugins sandbox, plugins nativos fornecem duas peças:
- Uma factory de descritor — retorna um
PluginDescriptorcomformat: "native"mais pontos de entrada relacionados ao admin. Importada porastro.config.mjsno momento do build. - Uma função
createPlugin(options)— o lado do runtime. Retorna um resultadodefinePlugin({ id, version, capabilities, hooks, routes, admin }).
Diferente dos plugins sandbox, ambas as peças podem ficar no mesmo arquivo porque não executam em ambientes diferentes — o plugin inteiro executa no mesmo processo. A exportação "." do pacote aponta para um arquivo que exporta tanto a factory de descritor quanto uma função createPlugin (ou default):
my-native-plugin/
├── src/
│ ├── index.ts # Factory de descritor + createPlugin
│ ├── admin.tsx # Componentes admin React (opcional)
│ └── astro/ # Componentes Astro para renderização de blocos PT (opcional)
│ └── index.ts
├── package.json
└── tsconfig.json
Configurar o pacote
O seguinte package.json declara os pontos de entrada e dependências peer que um plugin nativo precisa:
{
"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 dependências peer para que o site host forneça as versões reais e você não envie duplicatas.
Escrever o descritor e o runtime
O seguinte src/index.ts define a factory de descritor e o runtime createPlugin em um único arquivo:
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: "Tracking ID" },
enabled: { type: "boolean", label: "Enabled", default: options.enabled ?? true },
},
pages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Analytics plugin installed", { 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;
Detalhes importantes desta configuração:
format: "native"é obrigatório."native"também é o valor padrão, mas declará-lo explicitamente em cada descritor torna o formato fácil de identificar.entrypointé a exportação principal do pacote. O EmDash importa em runtime e chama a exportação padrão para construir o plugin resolvido.optionsfluem do descritor →createPlugin. Tudo que o usuário passa ao registrar o plugin (analyticsPlugin({ enabled: false })) é preservado no descritor e encaminhado paracreatePlugin. Plugins sandbox não têm esta superfície — leem configurações do KV em vez disso.id,versionecapabilitiesaparecem duas vezes. Uma no descritor, outra nodefinePlugin(). Devem coincidir. A cópia do descritor é o queastro.config.mjsvê no momento do build; a cópia dodefinePlugin()é o que executa no momento da requisição.- Handlers de rota nativos recebem um único argumento —
(ctx: RouteContext)ondectx.input,ctx.requestectx.requestMetasão mesclados com as propriedades regulares dePluginContext. Isso é o oposto da forma de dois argumentos do formato padrão. Veja Rotas 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ça com uma letra minúscula, depois letras, dígitos, hífens ou underscores. O id é usado como um segmento de caminho único em URLs de rotas do plugin e como parte de identificadores SQL gerados para índices de armazenamento do plugin, então qualquer coisa fora desse padrão falha em runtime. Os seguintes valores mostram quais ids são aceitos:
// Válido
"seo";
"audit-log";
"audit_log";
"plugin-forms";
// Inválido
"@my-org/plugin-forms"; // forma com escopo não permitida em runtime
"MyPlugin"; // sem maiúsculas
"42-plugin"; // não pode começar com 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. Os seguintes valores mostram quais strings de versão são aceitas:
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 factory de descritor e passe-a no array plugins: [] — plugins nativos sempre executam no mesmo 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 }),
],
}),
],
});
UI de configurações
Plugins nativos podem usar admin.settingsSchema para um formulário de configurações auto-gerado, que é o caminho mais simples:
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "API Key" },
enabled: { type: "boolean", label: "Enabled", default: true },
maxItems: { type: "number", label: "Max items", 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. Configurações são persistidas no mesmo armazenamento KV por plugin que plugins sandbox usam — leia com ctx.kv.get<T>("settings:<key>") de qualquer lugar.
O formulário gerado aparece atrás do ícone de engrenagem no cartão do plugin em Plugins (apenas admins — editar configurações de plugin requer a permissão plugins:manage). Campos secretos são somente escrita: o admin nunca vê o valor armazenado, apenas se um está definido.
Para UI de configurações mais rica do que settingsSchema fornece, envie páginas React personalizadas — veja Páginas admin e widgets React.
Exemplo completo — plugin de log de auditoria
O seguinte plugin registra cada criação, atualização e exclusão de conteúdo em armazenamento indexado e expõe uma rota de atividade recente:
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: "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: {
"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 o EmDash instalado.
- Registre seu plugin em
astro.config.mjs, importando-o diretamente do seu caminho de código fonte local. - Execute o servidor de desenvolvimento e acione hooks criando, atualizando ou excluindo conteúdo.
- Verifique o console para saída de
ctx.loge valide o armazenamento via rotas API.
Para testes unitários, faça mock da interface PluginContext e chame os handlers de hook diretamente.
Próximos passos
- Páginas admin e widgets React — forneça UI React personalizada para o painel admin.
- Componentes de renderização Portable Text — forneça componentes Astro que renderizam tipos de bloco definidos por plugins.
- Fragmentos de página — injete scripts, folhas de estilo ou HTML em páginas públicas.
- Distribuindo plugins nativos — empacotamento npm e versionamento.