/** * definePlugin() Helper * * Native plugin authoring entry. Returns a fully-resolved * `ResolvedPlugin` ready for the host integration to mount. * * Sandboxed plugins do NOT use this function. They default-export * a bare `{ hooks?, routes? }` object with a `satisfies SandboxedPlugin` * annotation from `emdash/plugin`. See the `emdash` changeset for the * authoring shape. */ import { normalizeCapabilities } from "./types.js"; import type { PluginDefinition, ResolvedPlugin, PluginHooks, ResolvedPluginHooks, ResolvedHook, HookConfig, PluginCapability, PluginStorageConfig, } from "./types.js"; /** * Define a native EmDash plugin. * * Native plugins ship as regular npm modules, get installed via * `pnpm add` + an `astro.config.mjs` edit, and run in the host * process. They have full access to the runtime — capabilities are * still enforced by `PluginContextFactory`, but there is no isolation * boundary. * * @example * ```typescript * import { definePlugin } from "emdash"; * * export default definePlugin({ * id: "my-plugin", * version: "1.0.0", * capabilities: ["content:read"], * hooks: { * "content:beforeSave": async (event, ctx) => { * ctx.log.info("Saving content", { collection: event.collection }); * return event.content; * } * }, * routes: { * "sync": { * handler: async (ctx) => { * return { status: "ok" }; * } * } * } * }); * ``` * * Sandboxed-format plugins do not use `definePlugin`. They * default-export a bare `{ hooks?, routes? }` object with a * `satisfies SandboxedPlugin` annotation from `emdash/plugin`. Calling * `definePlugin` with an object that has no `id` throws at runtime * (the type system already rejects it at compile time — this check is * for callers that bypass typechecking). */ export function definePlugin( definition: PluginDefinition, ): ResolvedPlugin { // Semantic check, not a structural one: `id` is what makes this a // native definition. Sandboxed plugins (the only other shape that // might land here at runtime) intentionally never have an `id` — // identity comes from the manifest's `slug` + `publisher`, computed // at install time. So "no id" is the unambiguous signal that the // caller meant the sandboxed authoring flow. if (typeof definition.id !== "string" || definition.id.length === 0) { throw new Error( `definePlugin() requires \`id\` (got ${typeof definition.id}). ` + "For native plugins, make sure your definition has both `id` and " + "`version`. For sandboxed plugins, drop `definePlugin()` entirely " + "and `export default { hooks, routes } satisfies SandboxedPlugin` " + 'from "emdash/plugin" — identity comes from `emdash-plugin.jsonc`.', ); } return defineNativePlugin(definition); } /** * Internal: define a native-format plugin with full validation and normalization. */ function defineNativePlugin( definition: PluginDefinition, ): ResolvedPlugin { // Declared function-local (not module scope) on purpose. Under // `ssr.noExternal` the worker entry can instantiate native plugins during a // circular module init, reaching this function before module-scope consts // initialize -> "Cannot access 'SIMPLE_ID' before initialization" -> every // route 500s on Cloudflare Workers. Call-time consts evaluate after the // literals are parsed, so the temporal dead zone cannot occur regardless of // bundle ordering. See #1370. // oxlint-disable-next-line e18e/prefer-static-regex -- call-time on purpose (see #1370) const SIMPLE_ID = /^[a-z0-9-]+$/; // oxlint-disable-next-line e18e/prefer-static-regex -- call-time on purpose (see #1370) const SCOPED_ID = /^@[a-z0-9-]+\/[a-z0-9-]+$/; // oxlint-disable-next-line e18e/prefer-static-regex -- call-time on purpose (see #1370) const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; const { id, version, capabilities = [], allowedHosts = [], hooks = {}, routes = {}, admin = {}, } = definition; // Default to empty object if no storage declared. // The empty object satisfies PluginStorageConfig (Record). // The cast is structurally safe because an empty record has no keys to conflict. const storage = (definition.storage ?? {}) as TStorage; // Validate id format: either simple (my-plugin) or scoped (@scope/my-plugin) // Simple: lowercase alphanumeric with dashes // Scoped: @scope/name where both parts are lowercase alphanumeric with dashes if (!SIMPLE_ID.test(id) && !SCOPED_ID.test(id)) { throw new Error( `Invalid plugin id "${id}". Must be lowercase alphanumeric with dashes (e.g., "my-plugin" or "@scope/my-plugin").`, ); } // Validate version format (basic semver) if (!SEMVER_PATTERN.test(version)) { throw new Error(`Invalid plugin version "${version}". Must be semver format (e.g., "1.0.0").`); } // Validate capabilities. Both current names and deprecated aliases are // accepted; aliases are silently rewritten to current names below so the // runtime only ever sees the canonical form. Authors are warned at // bundle/validate and hard-failed at publish. const validCapabilities = new Set([ // Current names "network:request", "network:request:unrestricted", "content:read", "content:write", "media:read", "media:write", "users:read", "email:send", "hooks.email-transport:register", "hooks.email-events:register", "hooks.page-fragments:register", // Deprecated aliases "network:fetch", "network:fetch:any", "read:content", "write:content", "read:media", "write:media", "read:users", "email:provide", "email:intercept", "page:inject", ]); for (const cap of capabilities) { if (!validCapabilities.has(cap)) { throw new Error(`Invalid capability "${cap}" in plugin "${id}".`); } } // Silent normalization: rewrite deprecated names to current names. Done // before the implication pass so implications work on canonical names. // `as PluginCapability[]` is safe because `normalizeCapabilities` only // returns strings from the validated input plus current names from the // rename map, all of which are in the union. const canonical = normalizeCapabilities(capabilities) as PluginCapability[]; // Capability implications: broader capabilities imply narrower ones. // Operates on canonical names only. const normalizedCapabilities: PluginCapability[] = [...canonical]; if (canonical.includes("content:write") && !canonical.includes("content:read")) { normalizedCapabilities.push("content:read"); } if (canonical.includes("media:write") && !canonical.includes("media:read")) { normalizedCapabilities.push("media:read"); } if ( canonical.includes("network:request:unrestricted") && !canonical.includes("network:request") ) { normalizedCapabilities.push("network:request"); } // Normalize hooks const resolvedHooks = resolveHooks(hooks, id); return { id, version, capabilities: normalizedCapabilities, allowedHosts, storage, hooks: resolvedHooks, routes, admin, }; } /** * Resolve hooks to normalized format with defaults. * * PluginHooks and ResolvedPluginHooks share the same keys — each input value is * `HookConfig | H` and the output is `ResolvedHook`. TS can't narrow * the handler type through a dynamic key, so we assert at the loop boundary. */ function resolveHooks(hooks: PluginHooks, pluginId: string): ResolvedPluginHooks { const resolved: ResolvedPluginHooks = {}; for (const key of Object.keys(hooks) as Array) { const hook = hooks[key]; if (hook) { (resolved as Record)[key] = resolveHook(hook, pluginId); } } return resolved; } /** * Check if a hook value is a config object (has a `handler` property) */ function isHookConfig( hook: HookConfig | THandler, ): hook is HookConfig { return typeof hook === "object" && hook !== null && "handler" in hook; } /** * Resolve a single hook to normalized format */ function resolveHook( hook: HookConfig | THandler, pluginId: string, ): ResolvedHook { // If it's a config object with handler property if (isHookConfig(hook)) { if (hook.exclusive !== undefined && typeof hook.exclusive !== "boolean") { throw new Error( `Invalid "exclusive" value in hook config for plugin "${pluginId}". Must be boolean.`, ); } return { priority: hook.priority ?? 100, timeout: hook.timeout ?? 5000, dependencies: hook.dependencies ?? [], errorPolicy: hook.errorPolicy ?? "abort", exclusive: hook.exclusive ?? false, handler: hook.handler, pluginId, }; } // It's just a handler function return { priority: 100, timeout: 5000, dependencies: [], errorPolicy: "abort", exclusive: false, handler: hook, pluginId, }; } export default definePlugin;