Minor Changes
-
#1057
c0ce915Thanks @ascorbic! - BREAKING (plugin authors): Reworks how sandboxed plugins are defined. ThedefinePlugin()helper is removed for sandboxed-format plugins; the new shape is a bare default export with asatisfies SandboxedPluginannotation. A new type-only subpathemdash/pluginprovides the types.This affects anyone writing a sandboxed plugin. Sites that use plugins are unaffected (see the per-plugin changesets for the import-shape change in published plugins).
- import { definePlugin, type ContentHookEvent, type PluginContext } from "emdash"; + import type { SandboxedPlugin } from "emdash/plugin"; - export default definePlugin({ + export default { hooks: { "content:beforeSave": { - handler: async (event: ContentHookEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { // ... return event.content; }, }, }, - }); + } satisfies SandboxedPlugin;
Three changes:
- Drop
import { definePlugin } from "emdash"and thedefinePlugin(...)wrapping call. Sandboxed plugins now default-export the bare object. import type { SandboxedPlugin } from "emdash/plugin"and addsatisfies SandboxedPluginto the default export. Theemdash/pluginsubpath is type-only — the bundler erases the import, so no runtime resolution ofemdashis needed (and the heavyemdashruntime no longer enters the plugin bundle).- Drop handler parameter annotations like
event: ContentSaveEvent, ctx: PluginContext. The strict mapped type onSandboxedPlugininfers them per hook name, with the full canonical event type. If you need to reference an event type by name (e.g. in a helper function),emdash/pluginre-exports them:import type { ContentHookEvent, PluginContext } from "emdash/plugin".
Why: the old
definePluginwas an identity function whose only job was to aliasemdashto a Proxy shim at build time so the import would resolve. With the new shape, sandboxed plugins have no runtimeemdashimport — only type-only imports fromemdash/plugin. The bundler doesn't need to alias anything; the build pipeline is simpler; and authors get strict per-hook event/return type inference for free.The trade-off: previously you could narrow an event type locally (e.g.
interface ContentSaveEvent { content: ... & { id: string } }). Under the strict mapped type, the canonical event type wins (TypeScript's contravariance on function parameters means narrowing isn't assignable). Authors validate fields at runtime withtypeof/isRecordchecks instead — which is the right pattern for input that comes from outside the type system anyway.Routes follow the same simplification. The two-arg
(routeCtx, ctx)shape is unchanged; only the annotations disappear:export default { routes: { health: async (routeCtx, ctx) => { // routeCtx: SandboxedRouteContext, ctx: PluginContext — both inferred. return new Response("ok"); }, }, } satisfies SandboxedPlugin;
SandboxedRouteContextexposes{ input, request, requestMeta? }.requestis typed asSandboxedRequest— a{ url, method, headers }record that's portable across in-process and isolate execution (Worker Loader can't pass realRequestobjects across the boundary).Native plugins are unaffected. This change applies only to sandboxed-format plugins. Native plugins continue to use
definePlugin()fromemdashand the existingPluginDefinitionshape.Type rename:
SandboxedPluginon theemdashpackage now refers to the new author-facing source-shape type. The runtime-side handle type (returned bySandboxRunner.load, held in the runtime's plugin cache) is renamed toSandboxedPluginInstance. If you importSandboxedPluginfromemdashto type a sandbox runner implementation or hold runtime plugin handles, update those imports toSandboxedPluginInstance. Public consumers of this type are mostly limited to@emdash-cms/cloudflareand other sandbox runner adapters; standard plugin / site code is unaffected.Removed types:
StandardPluginDefinition,StandardHookHandler,StandardHookEntry,StandardRouteHandler,StandardRouteEntryare no longer exported fromemdash. These were authoring-helper aliases under the old permissivedefinePluginstandard overload. UseSandboxedPluginfromemdash/pluginfor the same purpose under the new shape.Removed function:
isStandardPluginDefinitionis gone. There's no equivalent — sandboxed plugins are identified by structure ({ hooks?, routes? }) and you should treat the default export as already typed viasatisfies SandboxedPlugin. - Drop
-
#1052
0d5843fThanks @Rimander! - Fixes menu REST API consistency:POST /menus/:name/itemsno longer accepts unknown keys silently. Sendingcustom_url(snake_case) orurlused to return 201 withcustom_url: nullbecause Zod's default.strip()quietly dropped them. The schemas now use.strict()and return 400VALIDATION_ERRORwithUnrecognized key: "custom_url". The documented camelCase keys (customUrl,sortOrder,referenceCollection, etc.) are unchanged and persist as before. Thetypefield is now validated against the canonical enum ("custom" | "page" | "post" | "taxonomy" | "collection"); previously any string passed.- Moves per-item writes to
PUTandDELETE /menus/:name/items/:id(path-style). Every other EmDash resource (content,taxonomies,redirects,sections,widget-areas) addresses items by URL path; menus were the lone outlier requiring?id=<id>in the query string. The legacy query-string form is removed (it was undocumented and only used by the admin, which is updated in this PR). Callers should usePUT /menus/:name/items/:id/DELETE /menus/:name/items/:id. - Menu and menu-item API responses are now camelCase, aligning with the rest of EmDash's REST surface (
content,taxonomies,redirects, …).created_at→createdAt,updated_at→updatedAt,menu_id→menuId,parent_id→parentId,sort_order→sortOrder,reference_collection→referenceCollection,reference_id→referenceId,custom_url→customUrl,title_attr→titleAttr,css_classes→cssClasses,translation_group→translationGroup. Breaking for direct REST consumers that depend on snake_case keys in the response body. The admin UI is already updated. - Refactors menus to the standard repository pattern. Adds
MenuRepositorynext toContentRepository,TaxonomyRepository,RedirectRepository,MediaRepository,CommentRepository. Handlers become thin orchestrators; the repository is now the single place where snake_case rows become camelCase entities.
These changes do not touch any database schema or migration. Existing data is preserved.
-
#1011
dbaea9cThanks @ascorbic! - Adds experimental support for the decentralized plugin registry (see RFC #694). Configure withexperimental.registry.aggregatorUrlinastro.config.mjs; the admin UI then uses the registry instead of the centralized marketplace for browse and install. Marketplace behavior is unchanged when the option is not set.The experimental config accepts a
policy.minimumReleaseAgeduration (e.g."48h") that holds back releases below that age from install and update prompts, with apolicy.minimumReleaseAgeExcludeallowlist for trusted publishers or specific packages. The minimum-release-age check is enforced both client-side (for UX) and server-side (in the install endpoint), so stale browser tabs and deep links still hit the gate.
Patch Changes
-
#1076
6e62b90Thanks @ascorbic! - Fixes spurious TypeScript errors in strict projects that consume EmDash. Several subpaths (emdash/routes/*,emdash/api/route-utils,emdash/api/schemas,emdash/auth/providers/github,emdash/auth/providers/google) previously shipped raw source, so yourtscand editor type-checked EmDash's internals against your config and could report errors that weren't yours. These now ship compiled type declarations instead. The*-adminproviders andemdash/uistay source because they bridge the admin React/Astro runtime your own build processes. Import paths and runtime behaviour are unchanged. -
#1086
23597d0Thanks @ascorbic! - Fixes silent data loss in migration 036 on Cloudflare D1 (#1021). D1 ignoresPRAGMA foreign_keys = OFFand its replacementdefer_foreign_keysonly defers constraint validation, it doesn't suppress CASCADE actions, so dropping any table during the i18n rebuild fired its child cascades. Three FK relationships were affected:content_taxonomies.taxonomy_id -> taxonomies(id) ON DELETE CASCADEwiped all post-taxonomy associations.taxonomies.parent_id -> taxonomies(id) ON DELETE SET NULLflattened taxonomy hierarchies._emdash_menu_items.menu_id -> _emdash_menus(id) ON DELETE CASCADEwiped every menu item on the install (along withparent_id -> _emdash_menu_items(id) ON DELETE CASCADEmopping up nested items).
The migration now physically removes those FK relationships before any drop.
content_taxonomiesand_emdash_menu_itemsare rebuilt without their parent FKs as the first steps of up(), and the newtaxonomiesself-FK targets its temporary name (taxonomies_new) which SQLite rebinds on RENAME. The FKs from migration 005 on_emdash_menu_itemsare not restored on rollback either: the runtime always deleted child rows explicitly, so the cascade was redundant and reinstating it would only re-create the #1021 hazard on any future migration that drops_emdash_menus. Rollback also refuses to run whencontent_taxonomieshas rows referencing translation groups with no survivingtaxonomiesrow, surfacing dangling data before any destructive work, and theidx_content_taxonomies_termindex from migration 015 is restored after each rebuild.This is forward-fix only. Installs that already lost data when running 036 will need to restore from D1 Time Travel.
-
#1088
883b75bThanks @MA2153! - FixesEmDashClient.terms()returning{ terms }instead of{ items }, which causedpage.itemsto beundefinedfor any caller that iterated the result. The API handler returns{ terms: TermWithCount[] }but the client was typed and advertised asListResult<Term>— the key name mismatch is now mapped correctly. -
#751
05440b1Thanks @edrpls! - Fix the admin collection list pagination denominator so it no longer grows in increments of 5 as the user pages forward.The
GET /_emdash/api/content/{collection}response now includes atotalfield with the full filtered row count (independent oflimit). The admin uses it as the pagination denominator, so a 143-entry collection reads1/8on page 1 instead of1/5 → 5/10 → 10/15 → …as successive API pages load.The
totalfield is optional; pre-upgrade clients that ignore it still work, and the admin falls back to the loaded-item count when an older server doesn't return it.Also handles the edge case where the current page exceeds
totalPagesafter filtering or deletion — the admin clamps the active page so the table doesn't render empty while waiting for a refetch. -
#1000
94fb50bThanks @ask-bonk! - Fixes invite passkey registration behind a TLS-terminating reverse proxy. The inviteregister-optionsendpoint now resolves the public origin viagetPublicOrigin(url, emdash.config)before callinggetPasskeyConfig, matching every other passkey endpoint. Previously the WebAuthn RP ID fell back tourl.hostname(e.g.localhost), causing the browser to reject the registration with "Security error" when the public origin differed from the upstream host. -
#1013
0cd8c6dThanks @ascorbic! - Fixes the slash command menu's initial selection getting overridden when the menu opens under a stationary pointer. The menu items previously reacted tomouseenterunconditionally, so an item rendered beneath the cursor would steal selection from the keyboard default before any user interaction. Mouse-hover-selects still works, but only after the user actually moves the pointer over the menu. -
#1087
878a0b6Thanks @ascorbic! - Fixes two data-loss bugs in the WordPress WXR import path (admin UI Settings, Import, WordPress, i.e.POST /_emdash/api/import/wordpress/execute).Per-post taxonomy assignments parsed from
<wp:category>,<wp:tag>,<wp:term>, and per-item<category domain="...">blocks (#1061) are now persisted. The HTTP execute handler previously extracted this data and silently discarded it before any taxonomy or pivot rows were written. Terms are created idempotently in EmDash's seededcategoryandtagtaxonomies; custom taxonomies such asgenreare matched against existing EmDash definitions via the runtime's locale fallback chain (resolveLocaleChain), so imports against a non-default-locale site reuse defs seeded at the default locale instead of false-failing. Unknown custom taxonomies surface in a newresult.taxonomies.missingTaxonomiesfield instead of being silently dropped, so the admin can prompt the user to create the missing definition. Assignments respect each taxonomy definition'scollectionsarray.WPML and Polylang translations (#1080) are now imported under their own per-post locale and linked via
translation_group. Previously the entire upload shared oneconfig.localeand the second post of any translation pair was rejected by theUNIQUE(slug, locale)constraint introduced in migration 019. The parser promotes per-post locale from_icl_lang_code(WPML),trid(WPML's translation group id),_locale(Polylang), thelanguagetaxonomy, or_translationspostmeta. Terms are mirrored into each translation's locale so per-locale lookups (getTermsForEntry(..., locale)) resolve correctly on every translation row. Per-translation taxonomy assignments override anchor-inherited ones per-taxonomy when the translator picked different terms, matching WPML "Translate Independently" mode. Taxonomies the translation did not touch keep their inherited assignments, matching WPML "Sync" mode and Polylang's default.Adds
result.taxonomiesto the import response (additive). Existing consumers continue to work unchanged.Scope note: this fixes the HTTP import path, which is what the admin UI calls. The standalone
emdash import wordpressCLI command writes JSON files to disk and has its own slug-only output path that does not carry locale, so it can still clobber two translations with the samepost_name. That is a separate fix and not addressed here. -
#768
121f173Thanks @ask-bonk! - FixesSQLITE_CORRUPT_VTAB(database disk image is malformed) when editing or publishing content on collections that have search enabled, and on restore-from-trash, permanent-delete, and edit-while-trashed flows.The FTS5 sync triggers used the contentless-table form (
DELETE FROM fts WHERE rowid = OLD.rowid) on what is actually an external-content FTS5 table. After an UPDATE onec_<collection>, FTS5 then read NEW column values from the (already updated) content table while trying to remove OLD tokens from the inverted index, drifting the index out of sync until SQLite refused further reads. Rewrites the triggers to use the documented external-content-safeINSERT INTO fts(fts, rowid, ...) VALUES('delete', OLD.rowid, OLD.col1, ...)pattern, gated onOLD.deleted_at IS NULLso we don't try to remove rows that were never indexed (which would itself raiseSQLITE_CORRUPT_VTABon restore-from-trash and permanent-delete).Adds migration
039_fix_fts5_triggersthat rebuilds the FTS index for every search-enabled collection on upgrade, replacing the broken triggers and recovering from any latent index corruption left behind by earlier mutations. The migration runs once at startup before the first request can hit the affected paths, so upgrading sites get the fix on their next deploy without depending on a search-endpoint visit to trigger lazy auto-repair. -
#1077
f4a9711Thanks @ascorbic! - FixesAstro.locals.emdashtyping. The shipped type declaration referenced a build artifact that does not exist, solocals.emdashsilently fell back toanyin every EmDash site — losing autocomplete and type-checking on the handlers API in your pages and endpoints. It is now correctly typed asEmDashHandlers. -
#1019
5681eb2Thanks @ascorbic! - Fixes a Zod type-incompatibility between trusted plugins and core. Without a workspace-level pin, emdash'szod: ^4.3.5could resolve to a different patch than Astro's bundled Zod, and Zod 4 embeds the version in the type — so schemas imported viaastro/zodin trusted plugins (e.g.@emdash-cms/plugin-forms) were not assignable todefinePlugin'sPluginRoute<TInput>['input']. Pins Zod in the pnpm catalog so the entire workspace dedupes on one instance. -
#1074
ed917d9Thanks @ascorbic! - Fixes stored config sharing when the runtime module is loaded as both compileddistand rawsrcin the same process (Vite SSR / dual-package). The integration config is now keyed on a globalSymbol.forregistry entry instead of a typedglobalThisvar, matching the existing isolate-singleton pattern, sogetStoredConfig()resolves consistently across both module copies. -
#1076
6e62b90Thanks @ascorbic! - Fixes a type error in the shipped WordPress-plugin import source: the analyze-endpoint error body fromresponse.json()isunknownunder@cloudflare/workers-typesand was read without narrowing. This file ships as raw source via theemdash/routes/*export, so the error surfaced in strict consumer typechecks (issue #1053). The body is now typed before.messageis read; runtime behaviour is unchanged. -
Updated dependencies [
05440b1,484e7ab,0d5843f,0cd8c6d,d014b48,dbaea9c,5681eb2]:- @emdash-cms/admin@0.13.0
- @emdash-cms/auth@0.13.0
- @emdash-cms/auth-atproto@0.2.6
- @emdash-cms/gutenberg-to-portable-text@0.13.0