Plugins nativos podem carregar componentes React confiáveis na administração do EmDash. O host mantém o controle da navegação, roteamento de páginas, cartões do painel, layout do editor, tabelas de conteúdo, autenticação e limites de erro. O plugin fornece os componentes e os metadados necessários para posicioná-los.
Se o plugin só precisa de um formulário de configurações, comece com admin.settingsSchema. Ele usa os componentes de formulário do host e não exige um ponto de entrada React. Plugins em sandbox também podem usar este formulário gerado; as extensões React personalizadas desta página exigem um plugin nativo.
Formulário de configurações gerado
Declare settingsSchema na definição de runtime. O seguinte esquema produz um campo de texto multilinha, um select, uma entrada numérica, um interruptor e uma entrada de segredo somente gravação:
return definePlugin({
id: "plugin-activity",
version: "0.1.0",
admin: {
settingsSchema: {
projectName: {
type: "string",
label: "Project name",
description: "Name shown in activity exports",
},
notes: {
type: "string",
label: "Internal notes",
multiline: true,
},
mode: {
type: "select",
label: "Recording mode",
options: [
{ value: "creates", label: "New entries only" },
{ value: "all", label: "New and updated entries" },
],
default: "all",
},
retentionDays: {
type: "number",
label: "Retention in days",
min: 1,
max: 365,
default: 30,
},
enabled: {
type: "boolean",
label: "Record activity",
default: true,
},
exportToken: {
type: "secret",
label: "Export token",
},
},
},
});
Os campos disponíveis têm as seguintes opções. label é obrigatório e description é opcional para cada tipo.
type | Valor | Campos adicionais |
|---|---|---|
string | string | default, multiline |
number | number | default, min, max |
boolean | boolean | default |
select | string | options: Array<{ value, label }> obrigatório e default opcional |
secret | string | sem campos adicionais; o valor armazenado nunca é retornado ao navegador |
url | string | default, placeholder |
email | string | default, placeholder |
O formulário está disponível no controle de configurações do cartão do plugin em Plugins. Ler ou alterá-lo exige plugins:manage.
As configurações usam o armazenamento de configurações com namespace do plugin. Um campo chamado retentionDays está disponível para o plugin como retentionDays:
const retentionDays =
(await ctx.settings.get<number>("retentionDays")) ?? 30;
Os padrões do esquema preenchem o formulário gerado quando nenhum valor está armazenado, mas o EmDash não grava esses padrões no armazenamento de configurações. Aplique o mesmo fallback quando o runtime ler a configuração. Limpar um campo que não é segredo exclui seu valor armazenado e devolve o formulário ao padrão. Valores secretos são criptografados antes da persistência e nunca são enviados de volta ao navegador; o formulário informa apenas se um segredo está definido e permite que um administrador o substitua ou limpe.
Rótulos e descrições de configurações são renderizados conforme declarados. Se essas strings precisarem mudar com a locale da administração, construa em vez disso uma página de configurações React personalizada.
Ponto de entrada React
Uma extensão React confiável tem três declarações conectadas:
- O
adminEntrydo descritor diz ao Astro qual módulo empacotar na administração. - Os
admin.entry,admin.pageseadmin.widgetsdo runtime descrevem as superfícies de administração visíveis. - O módulo de administração exporta mapas de componentes cujas chaves correspondem aos caminhos de página e IDs de widget declarados. Os widgets de campo são a exceção: eles são resolvidos pelo nome a partir da exportação
fieldse não precisam de declaração.
O descritor só precisa do especificador do módulo. Metadados de página e widget pertencem à definição de runtime:
export function activityPlugin(): PluginDescriptor {
return {
id: "plugin-activity",
version: "0.1.0",
format: "native",
entrypoint: "@example/plugin-activity",
adminEntry: "@example/plugin-activity/admin",
};
}
export function createPlugin() {
return definePlugin({
id: "plugin-activity",
version: "0.1.0",
storage: {
events: { indexes: ["createdAt"] },
},
admin: {
entry: "@example/plugin-activity/admin",
pages: [{ path: "/activity", label: "Activity", icon: "clock" }],
widgets: [{ id: "recent-activity", title: "Recent activity" }],
},
});
}
Mantenha adminEntry e admin.entry idênticos. O primeiro é uma importação em tempo de build; o segundo diz ao runtime que o plugin usa componentes React de administração confiáveis.
Páginas de administração
Cada declaração de página tem os seguintes campos:
| Campo | Obrigatório | Comportamento |
|---|---|---|
path | Sim | Monta a página em /_emdash/admin/plugins/<plugin-id><path>. Use uma barra inicial. |
label | Sim | Fornece o rótulo da barra lateral e da paleta de comandos. |
icon | Não | Nomeia um ícone do Phosphor em formato kebab, snake, separado por espaços ou PascalCase. Nomes desconhecidos recorrem ao ícone do plugin. |
group | Não | Coloca a página em uma pasta recolhível da barra lateral. Um grupo que corresponda ao grupo de uma coleção exibida na barra lateral (diferencia maiúsculas de minúsculas e ignora espaços ao redor) coloca a página nessa pasta, depois de suas coleções e taxonomias; caso contrário, as páginas que compartilham o grupo, de qualquer plugin, formam uma pasta na seção Plugins. |
O módulo de administração mapeia cada caminho declarado para um componente React. Uma barra final é tratada como equivalente, e a raiz do plugin abre a primeira página exportada quando não existe uma página /.
A página a seguir carrega uma rota privada do plugin. Use Kumo para controles e apiFetch() para solicitações à API do plugin; apiFetch() adiciona o cabeçalho X-EmDash-Request: 1 exigido por rotas privadas autenticadas por cookie.
import { Button, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
import * as React from "react";
interface ActivitySummary {
count: number;
}
export function ActivityPage() {
const { i18n } = useLingui();
const [summary, setSummary] = React.useState<ActivitySummary>();
const [error, setError] = React.useState<string>();
const load = React.useCallback(async () => {
setError(undefined);
try {
const response = await apiFetch(
"/_emdash/api/plugins/plugin-activity/summary",
);
setSummary(
await parseApiResponse<ActivitySummary>(
response,
i18n._({ id: "activity.load-error", message: "Could not load activity" }),
),
);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
}, [i18n]);
React.useEffect(() => {
void load();
}, [load]);
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">
{i18n._({ id: "activity.title", message: "Activity" })}
</h1>
{summary ? (
<p>
{i18n._({ id: "activity.count", message: "Event count" })}: {summary.count}
</p>
) : error ? (
<p role="alert" className="text-kumo-danger">{error}</p>
) : (
<Loader />
)}
<Button type="button" onClick={() => void load()}>
{i18n._({ id: "activity.refresh", message: "Refresh" })}
</Button>
</section>
);
}
Defina a rota correspondente no runtime nativo. Handlers nativos recebem um argumento de contexto:
routes: {
summary: {
permission: "plugins:read",
handler: async (ctx) => ({
count: await ctx.storage.events.count(),
}),
},
},
Rotas privadas usam por padrão a permissão somente administrador plugins:manage. Declare a permissão existente mais estreita que corresponda à operação. Use public: true apenas para um endpoint destinado a tráfego de Internet não autenticado.
Exporte a página do ponto de entrada de administração:
import type { PluginAdminExports } from "emdash";
import { ActivityPage } from "./ActivityPage.js";
export const pages: PluginAdminExports["pages"] = {
"/activity": ActivityPage,
};
Rótulos de página passam pela instância Lingui compartilhada da administração. Um rótulo como Settings usa a tradução da administração quando existe. Um plugin pode carregar seu próprio catálogo de mensagens na instância compartilhada para rótulos e mensagens de componentes específicos do plugin; caso contrário, a mensagem em inglês declarada é o fallback.
Carregue o catálogo do plugin quando o ponto de entrada de administração for importado, e carregue-o novamente depois que o administrador mudar a locale. O pequeno catálogo alemão a seguir usa os mesmos IDs dos exemplos de página e widget:
import { i18n } from "@lingui/core";
const catalogs: Record<string, Record<string, string>> = {
de: {
Activity: "Aktivität",
"activity.title": "Aktivität",
"activity.count": "Ereignisanzahl",
"activity.refresh": "Aktualisieren",
"activity.load-error": "Aktivität konnte nicht geladen werden",
"activity.unavailable": "Nicht verfügbar",
"activity.default-locale": "Standardsprache",
},
};
function loadPluginCatalog() {
const messages = catalogs[i18n.locale];
if (!messages || "activity.title" in i18n.messages) return;
i18n.load(i18n.locale, messages);
}
loadPluginCatalog();
i18n.on("change", loadPluginCatalog);
Importe o carregador pelo efeito colateral de registro antes de exportar componentes:
import "./i18n.js";
// Page, widget, panel, and column exports follow.
A administração substitui seu catálogo ativo quando a locale muda. O listener change restaura as mensagens do plugin, e a verificação do ID da mensagem impede que i18n.load() dispare um loop. Para mais locales, gere os objetos de mensagem com o build Lingui do plugin em vez de mantê-los à mão. Mantenha @lingui/core e @lingui/react como peer dependencies para que o plugin use a instância compartilhada do host.
Widgets do painel
Uma declaração de widget tem um id obrigatório e title e size opcionais:
admin: {
entry: "@example/plugin-activity/admin",
widgets: [
{ id: "recent-activity", title: "Recent activity", size: "half" },
],
},
Exporte um componente sob o mesmo ID. Este widget lê a mesma rota de resumo da página e fornece apenas o conteúdo do cartão; o EmDash fornece o cartão do painel ao redor e o título.
import { useLingui } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import type { PluginAdminExports } from "emdash";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
interface ActivitySummary {
count: number;
}
async function loadSummary(fallbackMessage: string) {
const response = await apiFetch(
"/_emdash/api/plugins/plugin-activity/summary",
);
return parseApiResponse<ActivitySummary>(
response,
fallbackMessage,
);
}
function RecentActivityWidget() {
const { i18n } = useLingui();
const { data, isLoading, isError } = useQuery({
queryKey: ["plugin-activity", "summary"],
queryFn: () =>
loadSummary(
i18n._({ id: "activity.load-error", message: "Could not load activity" }),
),
});
return (
<p>
{i18n._({ id: "activity.count", message: "Event count" })}:{" "}
{isLoading
? "…"
: isError
? i18n._({ id: "activity.unavailable", message: "Unavailable" })
: (data?.count ?? 0)}
</p>
);
}
export const widgets: PluginAdminExports["widgets"] = {
"recent-activity": RecentActivityWidget,
};
O host coloca o componente dentro de um cartão do painel e renderiza title como título. Mantenha o componente compacto e não adicione um segundo invólucro de cartão. size aceita full, half ou third; é armazenado como dica de layout, mas o painel atual renderiza widgets do plugin em sua grade responsiva de duas colunas sem aplicar essa dica.
Widgets de campo personalizados
Um widget de campo substitui o editor de um campo do esquema por um componente do seu plugin. Exporte um mapa fields a partir do módulo de administração, indexado pelo nome do widget:
import { useLingui } from "@lingui/react";
interface FieldWidgetProps {
value: unknown;
onChange: (value: unknown) => void;
label: string;
id: string;
required?: boolean;
options?: Record<string, unknown> | Array<{ value: string; label: string }>;
validation?: Record<string, unknown>;
minimal?: boolean;
}
function RatingField({ value, onChange, label, id, options }: FieldWidgetProps) {
const { i18n } = useLingui();
const max = !Array.isArray(options) && typeof options?.max === "number" ? options.max : 5;
const rating = typeof value === "number" ? value : 0;
return (
<div role="group" aria-labelledby={`${id}-label`} className="grid gap-2">
<span id={`${id}-label`} className="text-sm font-medium">
{label}
</span>
<div className="flex gap-1">
{Array.from({ length: max }, (_, index) => index + 1).map((step) => (
<button
key={step}
type="button"
id={step === 1 ? id : undefined}
aria-pressed={step <= rating}
aria-label={i18n._({
id: "rating.set",
message: "Rate {step}",
values: { step },
})}
onClick={() => onChange(step === rating ? null : step)}
>
{step <= rating ? "★" : "☆"}
</button>
))}
</div>
</div>
);
}
export const fields = {
rating: RatingField,
};
O plugin precisa do par adminEntry e admin.entry descrito em Ponto de entrada React para que o módulo seja carregado. Nada mais no descritor ou na chamada a definePlugin() faz referência a um widget de campo.
Um campo do esquema ativa o widget definindo widget como <plugin-id>:<widget-name>. O ID do plugin é o id passado a definePlugin(), e o nome do widget é uma chave da exportação fields. O campo de seed a seguir usa o widget acima em um plugin cujo ID é plugin-reviews:
{
"slug": "rating",
"label": "Rating",
"type": "integer",
"widget": "plugin-reviews:rating",
"options": { "max": 5 }
}
A API de esquema também aceita widget. Escolha um type de campo que possa conter o valor produzido pelo widget; o widget altera apenas o editor, não a forma como o valor é armazenado.
O componente recebe as seguintes props:
| Prop | Tipo | Comportamento |
|---|---|---|
value | unknown | O valor atual do campo no editor. Restrinja o tipo antes de usá-lo. |
onChange | (value: unknown) => void | Chame com o novo valor, não com um evento. |
label | string | O rótulo do campo. O host não renderiza um em volta de um widget de plugin, então renderize você mesmo. |
id | string | Um ID do DOM para o campo, no formato field-<slug>. |
required | boolean (opcional) | A configuração de obrigatoriedade do campo. |
options | object ou Array<{ value, label }> (opcional) | As options do campo no esquema, repassadas sem alteração. Use-as para configuração por campo, como max acima. Quando o campo tem validation.options (campos select e multiSelect), o host passa essas no lugar, como uma lista de itens { value, label }. |
validation | object (opcional) | As regras de validation do campo no esquema. |
minimal | boolean (opcional) | true quando o host pede uma renderização compacta, sem o contorno do rótulo ao redor. |
O editor envolve cada widget em um error boundary. Se o componente lançar um erro ao renderizar, o campo mostra uma mensagem “Plugin widget error” com um botão de nova tentativa, em vez de desmontar o editor. Se a exportação fields não tiver nenhuma função com o nome solicitado, o editor usa o editor padrão do tipo de campo. Um valor de widget sem : registra um aviso no console e também recorre ao editor padrão.
Painéis do editor de conteúdo
Um painel do editor adiciona uma seção emoldurada pelo host à barra lateral de configurações de uma entrada salva. Não é montado para uma nova entrada porque ainda não existe um entry salvo.
Painéis e colunas de lista de conteúdo são descobertos diretamente do módulo de administração confiável. Precisam de adminEntry e admin.entry para o módulo carregar, mas não precisam de entradas em admin.pages ou admin.widgets.
import type {
ContentEditorPanelContext,
ContentEditorPanelExtension,
} from "@emdash-cms/admin";
import { useLingui } from "@lingui/react";
function ActivityPanel({ entry, collection, locale }: ContentEditorPanelContext) {
const { i18n } = useLingui();
const displayLocale =
locale ??
i18n._({ id: "activity.default-locale", message: "Default locale" });
return (
<p className="text-sm text-kumo-subtle">
{collection}/{entry.slug} ({displayLocale})
</p>
);
}
export const contentEditorPanels = [
{
id: "activity-summary",
title: "Activity summary",
component: ActivityPanel,
collections: ["posts", "pages"],
order: 10,
},
] satisfies readonly ContentEditorPanelExtension[];
Os campos do painel têm o seguinte comportamento:
id,titleecomponentsão obrigatórios. O ID deve ser exclusivo entre os painéis deste plugin.collectionsé um array de nomes de coleção ou um predicado. Omita-o para mostrar o painel em todas as coleções.minRoleé um limiar numérico de visibilidade. Não autoriza chamadas de API.orderordena valores mais baixos primeiro. Empates usam o ID do plugin e o ID do painel.
O componente recebe o entry salvo, sua collection e o locale resolvido. Mantenha o layout responsivo à barra lateral estreita. O EmDash isola falhas de componente e de predicado de coleção para que um painel não possa desmontar o editor.
Colunas de lista de conteúdo
Uma coluna de lista de conteúdo adiciona células somente leitura às listas de coleção ativas. O host ainda é dono da paginação, ações de linha, estados de carregamento e vazio, e da própria tabela. Colunas não são mostradas na Lixeira.
A coluna a seguir usa visibleItems para buscar uma página de status. Cada célula usa a mesma chave React Query, então as solicitações compartilham um resultado em vez de emitir uma solicitação por linha.
import { useQuery } from "@tanstack/react-query";
import type {
ContentListColumnCellContext,
ContentListColumnExtension,
} from "@emdash-cms/admin";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
async function loadStatuses(
collection: string,
locale: string | undefined,
ids: readonly string[],
) {
const response = await apiFetch(
"/_emdash/api/plugins/plugin-activity/statuses",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collection, locale, ids }),
},
);
return parseApiResponse<Record<string, string>>(
response,
"Could not load activity statuses",
);
}
function ActivityCell({
item,
visibleItems,
collection,
locale,
}: ContentListColumnCellContext) {
const ids = visibleItems.map((visibleItem) => visibleItem.id);
const { data } = useQuery({
queryKey: ["plugin-activity", "statuses", collection, locale ?? null, ids],
queryFn: () => loadStatuses(collection, locale, ids),
});
return <span>{data?.[item.id] ?? "-"}</span>;
}
export const contentListColumns = [
{
id: "activity",
label: "Activity",
cell: ActivityCell,
collections: ["posts", "pages"],
align: "end",
order: 10,
},
] satisfies readonly ContentListColumnExtension[];
Os campos de coluna têm o seguinte comportamento:
id,labelecellsão obrigatórios. O ID deve ser exclusivo entre as colunas deste plugin.headersubstitui o conteúdo do cabeçalho por um componente;labelpermanece o fallback do host.collections,minRoleeordercomportam-se como seus equivalentes de painel.alignaceitastartouende usa alinhamento lógico para locales da esquerda para a direita e da direita para a esquerda.
Colunas não podem adicionar ordenação ou filtragem só no navegador. Esses controles afetariam apenas a página de cursor carregada, não toda a coleção no servidor.
Plugins desabilitados
Quando um administrador desabilita o plugin, o EmDash remove suas páginas, widgets, painéis e colunas da administração. Suas rotas privadas retornam não encontrado, e seus hooks param de executar. Reabilitar o plugin reconstrói o pipeline de hooks e torna suas exportações de administração confiáveis novamente disponíveis.
Empacotar o ponto de entrada
Exporte o módulo de administração separadamente do runtime do servidor para que o Astro possa empacotá-lo para o navegador com as instâncias React, Kumo e Lingui do host. Distribuir plugins nativos fornece o layout completo do pacote, exportações, peer dependencies e comandos de build.