I plugin nativi possono caricare componenti React attendibili nell’admin EmDash. L’host mantiene il controllo di navigazione, routing delle pagine, card della dashboard, layout dell’editor, tabelle dei contenuti, autenticazione e confini di errore. Il plugin fornisce i componenti e i metadati necessari per posizionarli.
Se il plugin necessita solo di un modulo di impostazioni, inizia con admin.settingsSchema. Usa i componenti form dell’host e non richiede un entrypoint React. Anche i plugin sandbox possono usare questo form generato; le estensioni React personalizzate di questa pagina richiedono un plugin nativo.
Form di impostazioni generato
Dichiara settingsSchema nella definizione runtime. Lo schema seguente produce un campo di testo multilinea, un select, un input numerico, uno switch e un input secret solo in scrittura:
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",
},
},
},
});
I campi disponibili hanno le seguenti opzioni. label è obbligatorio e description è opzionale per ogni tipo.
type | Value | Additional fields |
|---|---|---|
string | string | default, multiline |
number | number | default, min, max |
boolean | boolean | default |
select | string | required options: Array<{ value, label }> and optional default |
secret | string | no additional fields; the stored value is never returned to the browser |
url | string | default, placeholder |
email | string | default, placeholder |
Il form è disponibile dal controllo impostazioni sulla card del plugin in Plugins. Leggerlo o modificarlo richiede plugins:manage.
Le impostazioni usano lo store di impostazioni con namespace del plugin. Un campo chiamato retentionDays è disponibile per il plugin come retentionDays:
const retentionDays =
(await ctx.settings.get<number>("retentionDays")) ?? 30;
I default dello schema riempiono il form generato quando non è memorizzato alcun valore, ma EmDash non scrive quei default nello store di impostazioni. Applica lo stesso fallback quando il runtime legge l’impostazione. Cancellare un campo non secret elimina il valore memorizzato e riporta il form al default. I valori secret vengono crittografati prima della persistenza e non vengono mai restituiti al browser; il form segnala solo se un secret è impostato e consente a un amministratore di sostituirlo o cancellarlo.
Le etichette e le descrizioni delle impostazioni vengono renderizzate come dichiarate. Se quelle stringhe devono cambiare con la locale dell’admin, crea invece una pagina di impostazioni React personalizzata.
Entrypoint React
Un’estensione React attendibile ha tre dichiarazioni collegate:
- L’
adminEntrydel descriptor indica ad Astro quale modulo includere nel bundle dell’admin. - I campi runtime
admin.entry,admin.pageseadmin.widgetsdescrivono le superfici admin visibili. - Il modulo admin esporta mappe di componenti le cui chiavi corrispondono ai path di pagina e agli ID widget dichiarati.
Il descriptor necessita solo dello specifier del modulo. I metadati di pagina e widget appartengono alla definizione 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" }],
},
});
}
Mantieni adminEntry e admin.entry identici. Il primo è un’importazione in fase di build; il secondo indica al runtime che il plugin usa componenti React admin attendibili.
Pagine admin
Ogni dichiarazione di pagina ha i seguenti campi:
| Field | Required | Behavior |
|---|---|---|
path | Yes | Mounts the page at /_emdash/admin/plugins/<plugin-id><path>. Use a leading slash. |
label | Yes | Supplies the sidebar and command-palette label. |
icon | No | Names a Phosphor icon in kebab, snake, space-separated, or PascalCase form. Unknown names fall back to the plugin icon. |
Il modulo admin mappa ogni path dichiarato a un componente React. Uno slash finale è trattato come equivalente, e la root del plugin apre la prima pagina esportata quando non esiste una pagina /.
La pagina seguente carica una route privata del plugin. Usa Kumo per i controlli e apiFetch() per le richieste API del plugin; apiFetch() aggiunge l’header X-EmDash-Request: 1 richiesto dalle route private autenticate con 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>
);
}
Definisci la route corrispondente nel runtime nativo. Gli handler nativi ricevono un argomento di contesto:
routes: {
summary: {
permission: "plugins:read",
handler: async (ctx) => ({
count: await ctx.storage.events.count(),
}),
},
},
Le route private usano per impostazione predefinita il permesso solo amministratore plugins:manage. Dichiara il permesso esistente più stretto che corrisponde all’operazione. Usa public: true solo per un endpoint destinato al traffico Internet non autenticato.
Esporta la pagina dall’entrypoint admin:
import type { PluginAdminExports } from "emdash";
import { ActivityPage } from "./ActivityPage.js";
export const pages: PluginAdminExports["pages"] = {
"/activity": ActivityPage,
};
Le etichette delle pagine passano attraverso l’istanza Lingui condivisa dell’admin. Un’etichetta come Settings usa la traduzione dell’admin quando esiste. Un plugin può caricare il proprio catalogo di messaggi nell’istanza condivisa per etichette e messaggi di componenti specifici del plugin; altrimenti il messaggio inglese dichiarato è il fallback.
Carica il catalogo del plugin quando viene importato l’entrypoint admin, poi caricalo di nuovo dopo che l’amministratore cambia locale. Il piccolo catalogo tedesco seguente usa gli stessi ID degli esempi di pagina 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);
Importa il loader per il suo effetto collaterale di registrazione prima di esportare i componenti:
import "./i18n.js";
// Page, widget, panel, and column exports follow.
L’admin sostituisce il catalogo attivo quando cambia la locale. Il listener change ripristina i messaggi del plugin, e il controllo dell’ID messaggio impedisce a i18n.load() di attivare un loop. Per altre locale, genera gli oggetti messaggio con la build Lingui del plugin invece di mantenerli a mano. Mantieni @lingui/core e @lingui/react come peer dependency così il plugin usa l’istanza condivisa dell’host.
Widget della dashboard
Una dichiarazione di widget ha un id obbligatorio e title e size opzionali:
admin: {
entry: "@example/plugin-activity/admin",
widgets: [
{ id: "recent-activity", title: "Recent activity", size: "half" },
],
},
Esporta un componente con lo stesso ID. Questo widget legge la stessa route di riepilogo della pagina e fornisce solo il contenuto della card; EmDash fornisce la card della dashboard circostante e l’intestazione.
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,
};
L’host colloca il componente in una card della dashboard e renderizza title come intestazione. Mantieni il componente compatto e non aggiungere un secondo involucro di card. size accetta full, half o third; viene memorizzato come suggerimento di layout, ma la dashboard attuale renderizza i widget del plugin nella griglia responsive a due colonne senza applicare quel suggerimento.
Pannelli dell’editor di contenuti
Un pannello dell’editor aggiunge una sezione incorniciata dall’host alla barra laterale delle impostazioni di una voce salvata. Non viene montato per una nuova voce perché non esiste ancora un entry salvato.
I pannelli e le colonne dell’elenco contenuti vengono scoperti direttamente dal modulo admin attendibile. Hanno bisogno di adminEntry e admin.entry affinché il modulo si carichi, ma non di voci in admin.pages o 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[];
I campi del pannello hanno il seguente comportamento:
id,titleecomponentsono obbligatori. L’ID deve essere univoco tra i pannelli di questo plugin.collectionsè un array di nomi di collection o un predicato. Omettilo per mostrare il pannello per ogni collection.minRoleè una soglia numerica di visibilità. Non autorizza le chiamate API.orderordina prima i valori più bassi. I pareggi usano l’ID del plugin e l’ID del pannello.
Il componente riceve l’entry salvato, la sua collection e il locale risolto. Mantieni il layout responsive alla barra laterale stretta. EmDash isola i fallimenti del componente e del predicato di collection così un pannello non può smontare l’editor.
Colonne dell’elenco contenuti
Una colonna dell’elenco contenuti aggiunge celle di sola lettura agli elenchi di collection attivi. L’host possiede ancora paginazione, azioni di riga, stati di caricamento e vuoto, e la tabella stessa. Le colonne non vengono mostrate nel Cestino.
La colonna seguente usa visibleItems per recuperare una pagina di stati. Ogni cella usa la stessa chiave React Query, così le richieste condividono un risultato invece di emettere una richiesta per riga.
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[];
I campi della colonna hanno il seguente comportamento:
id,labelecellsono obbligatori. L’ID deve essere univoco tra le colonne di questo plugin.headersostituisce il contenuto dell’intestazione con un componente;labelrimane il fallback dell’host.collections,minRoleeordersi comportano come i loro equivalenti del pannello.alignaccettastartoende usa l’allineamento logico per le locale da sinistra a destra e da destra a sinistra.
Le colonne non possono aggiungere ordinamento o filtro solo browser. Quei controlli influenzerebbero solo la pagina del cursore caricata, non l’intera collection lato server.
Plugin disabilitati
Quando un amministratore disabilita il plugin, EmDash rimuove le sue pagine, widget, pannelli e colonne dall’admin. Le sue route private restituiscono non trovato e i suoi hook smettono di eseguirsi. Riabilitare il plugin ricostruisce la pipeline degli hook e rende di nuovo disponibili le sue esportazioni admin attendibili.
Pacchettizzare l’entrypoint
Esporta il modulo admin separatamente dal runtime del server così Astro può includerlo nel bundle per il browser con le istanze React, Kumo e Lingui dell’host. Distribuire plugin nativi fornisce il layout completo del package, le esportazioni, le peer dependency e i comandi di build.