github emdash-cms/emdash emdash@0.40.0

Minor Changes

  • #3364 c99bcd3 Thanks @khoinguyenpham04! - Adds bulk tagging for editors. Select posts from a collection's bulk-actions bar or paste up to 50 public links from the Tags page, review their exact titles and languages, and apply one existing or new tag. Assignments take effect immediately without publishing other draft edits, preserve existing tags, and report unmatched links or failed writes for retry.

  • #3320 840a9d3 Thanks @ascorbic! - Adds first-class blocks fields for ordered, typed page compositions. Define retained block-type versions through the schema API, MCP, or seed files; edit block cards in the admin; and render stored compositions with <Blocks value components fallback> from emdash/ui.

    Generated collection types include each retained block version, and defineBlockComponents<T>() type-checks that a component map covers every generated _type. Image, file, repeater-image, and Portable Text media inside blocks participate in normalization, MIME validation, usage tracking, and cleanup protection.

    Deploy renderer support before activating a breaking block-type version. Existing versions remain available for drafts, revisions, and stored content, and migrating a stored block to a new version requires explicit migrateBlocks: true intent.

    Sites upgrading from a release older than 0.39 must deploy 0.39 first and upgrade every runtime before creating a blocks field. The 0.39 unknown-field protection prevents an older runtime in a rolling deployment or rollback from overwriting block JSON.

  • #3167 ed51c68 Thanks @kwmr! - Adds optional link on portable-text image blocks: editor link buttons work on image selection, and Image.astro wraps images in a sanitized <a> when link.href is set. Legacy link: "https://…" strings written by WordPress/Gutenberg imports are normalised on read, so already-imported linked images keep their link and are upgraded to the object shape on their next edit.

  • #1944 bf6b0a9 Thanks @swissky! - Localizes invite, magic-link, and account-recovery emails: they now follow the site locale (falling back to the requesting user's admin language) instead of always being sent in English. Email HTML sets lang and dir on the root element, so right-to-left languages render correctly. A non-canonical site locale (pt-br) is normalized to its catalog (pt-BR); an unsupported value falls back to the requesting user's admin language.

    @emdash-cms/auth's invite and magic-link builders (buildInviteEmail, buildMagicLinkEmail, now exported) accept optional injected copy and locale via new emailStrings/emailLocale config options (InviteEmailStrings/MagicLinkEmailStrings). @emdash-cms/admin/locales exports the copy resolvers getInviteEmailStrings/getMagicLinkEmailStrings and the BCP 47 matcher matchLocale.

  • #3333 26f2076 Thanks @swissky! - Adds optional cc and replyTo fields to plugin email messages, including from sandboxed plugins:

    await ctx.email.send({ to, cc: ["team@example.com"], replyTo: visitorEmail, subject, text });

    ctx.email.send() throws when cc is not an array of strings or replyTo is not a string. email:beforeSend, email:deliver, and email:afterSend hooks receive both fields on event.message, and the development console provider prints them. The Cloudflare email provider delivers both, and a message's replyTo overrides the provider's configured replyTo. Custom email:deliver providers should pass cc and replyTo to their email service.

  • #3360 6f83886 Thanks @danielmlr! - Updates taxonomy definitions so hierarchical and collections belong to the taxonomy instead of to each locale. Every locale reads the same values, and translations can no longer disagree about whether a taxonomy is hierarchical or which collections it applies to. label and labelSingular stay per locale.

    This changes behavior for multilingual sites:

    • PUT /_emdash/api/taxonomies/{name} and the MCP taxonomy_update tool change hierarchical and collections for every locale, even when locale is passed. Previously they changed only the addressed locale's definition.
    • Creating a definition for a name that already exists in another locale joins that taxonomy, with or without translationOf, instead of starting a separate translation group. It takes the taxonomy's hierarchical and collections; a create that sends different values returns VALIDATION_ERROR, where it previously stored them for the new locale only. Change them with an update instead.
    • getTaxonomyTerms(), getTaxonomyDef() and getTaxonomyDefs() return a taxonomy in every locale, including a locale without its own definition, which takes its label from the first locale on its fallback chain that has one, else the default locale, else the lowest locale code. Previously such a locale got no definition and an empty term list, so the built-in Categories and Tags widgets rendered empty on sites whose default locale had no definition.
    • Seed files may omit hierarchical and collections on a taxonomy entry whose translationOf points at an entry with the same name. Applying a seed follows translationOf through entries with the same name and takes them from the last one, whatever the translations carry and wherever the entries appear in the file. Validation warns when a translation declares values other than the ones it takes, or when two entries that declare them for one taxonomy disagree. The exported SeedTaxonomy type marks both fields optional, so code that reads them from a seed must handle undefined. emdash export-seed writes them only on the entry the translations point at.
    • Importing a WordPress export (WXR) into a locale assigns the terms of a taxonomy that is defined only in another locale. Previously the import skipped them and reported the taxonomy as missing.

    The upgrade migration merges existing definitions of each taxonomy name into one. Where locales disagreed, the taxonomy becomes hierarchical if any locale's definition was, and applies to every collection any locale's definition listed, so no term tree flattens and no collection loses a taxonomy it showed in some locale. Definitions of one name that were in separate translation groups are joined into one group. Definitions of different names that shared a translation group, which older seed files and API versions could create, get one group per name.

    What should I do?

    If your site defines different hierarchical or collections values per locale on purpose, check them after upgrading and set the values you want once.

    If your code changes hierarchical or collections by writing to _emdash_taxonomy_defs with SQL, use the taxonomy API, the MCP taxonomy_update tool or a seed file instead. A direct write no longer changes what EmDash reads, only what sandboxed plugins see. EmDash still updates those columns whenever a taxonomy changes, for code that reads them.

    @emdash-cms/plugin-test: runtimeHost.fixtures.taxonomyDefinition() sets hierarchical and collections for every locale of the taxonomy.

  • #3346 1796cd5 Thanks @ascorbic! - Adds site transfer: export a whole EmDash site as a .emdash package and import it into an empty EmDash site, on Cloudflare (D1 and R2) or Node (SQLite or PostgreSQL). A package carries content, revisions, schema and block types, taxonomies, bylines, menus, widgets, redirects, SEO, settings, media files, and comments unless you leave them out. Imports are checked before anything is written, run in bounded steps, and end with a receipt issued only after the imported site is verified against the package.

    From the command line

    emdash site export --output site.emdash --url https://old.example.com
    emdash site import site.emdash --analyze --url https://new.example.com
    emdash site import site.emdash --plan sha256:… --confirm --url https://new.example.com
    • site export runs the export on the server, downloads and checks it file by file, and writes the package atomically. --no-comments leaves out comments and reactions. Run it again with the same --output to resume an interrupted export.
    • site import <file> --analyze checks the package locally, uploads only what the target is missing, and prints the plan: record counts, package users and suggested matches, the changes the import makes, warnings, blockers, and the plan digest. Map package users with --map-principal <id or email>=<user id, email, or none> (repeatable), and keep this site's title or tagline with --use-target-title and --use-target-tagline. The command exits with 2 when the plan has blockers.
    • site import <file> --plan <digest> --confirm runs the reviewed plan and prints the receipt. It refuses to run if the plan changed after review.
    • site import status|resume|receipt <operation-id> inspects, continues, or prints the receipt of an import. Resuming an import that is still uploading needs the package file as a third argument.

    Every command accepts --json; errors print as { "error": { "code", "message" } }. The client adds matching transfer* methods and users().

    REST API

    The API lives under /_emdash/api/admin/transfer. Exports: POST exports (with an optional Idempotency-Key), POST exports/:id/advance until nextRequestInMs is null, then GET exports/:id/archive for one archive or GET exports/:id/manifest and GET exports/:id/files/<path> file by file. Imports: POST imports with the package's manifest.json, PUT imports/:id/files/<path> for each file listed by GET imports/:id/missing, POST imports/:id/analyze until the plan is ready (optionally with decisions), POST imports/:id/execute with the reviewed { packageDigest, planDigest }, POST imports/:id/advance until it finishes, and GET imports/:id/receipt. GET capabilities reports supported formats and limits and whether this site can receive an import.

    A plan lists every change the import makes as transformations, including those the exporter already made (for example, records left out because their parent no longer existed, or content that refers to media files the origin site did not have). warnings hold findings that need attention, and any blockers prevent execution.

    MCP

    The MCP server adds site_transfer_capabilities, site_export_start, site_export_status, site_import_analyze, site_import_start, site_import_resume, site_import_status, and site_import_receipt. Package files are uploaded over HTTP. Tool results are bounded summaries and never include record values, principal emails, media bytes, or download URLs.

    Access

    Site transfer needs the transfer:export or transfer:import permission (admins). API and OAuth tokens need transfer:export to export, transfer:analyze to upload and analyze, and transfer:execute to run, cancel, or abandon an import. The admin scope includes all three, so existing admin tokens and emdash login credentials can use site transfer; grant a single transfer scope to give a token, such as an agent's, narrower access.

    An MCP token without admin or the needed transfer scope can still start an export or import once an admin approves it: the tool fails with TRANSFER_APPROVAL_REQUIRED and an approvalId, a signed-in admin approves the request under Settings → Transfer (or POST approvals/:id/approve), and the agent repeats the call with approvalId. A request must be approved within 15 minutes, and an approval must be used within 15 minutes. It is single-use and bound to the user, the token, and the exact request.

    The audit log records who creates an export or import, requests, cancels, or abandons an import, or approves or denies a transfer request, and whose request completed or failed each import. Entries hold operation and approval ids, digests, record counts, and error codes, never package content.

    Writes during an import

    While an import is writing, and after a failed or cancelled import until it is abandoned, content and settings changes through the REST API, MCP, public comment and reaction forms, plugin routes, sandboxed plugins, and scheduled publishing return 503 TRANSFER_IMPORT_IN_PROGRESS. Reads, sign-in, user management, API token management, and entry edit locks keep working. Do not send visitors to an imported site until its receipt is available.

    When the site cannot check whether writes are allowed, writes that returned 503 MEDIA_USAGE_ACTIVATION_CHECK_FAILED now return 503 TRANSFER_FENCE_CHECK_FAILED, in REST, MCP, and plugin route responses. Update any client that matches the old code.

    The public media route now refuses storage keys under transfers/. System cleanup removes an import's staged files 24 hours after it finishes and an export's when it expires.

  • #3376 3be2921 Thanks @ascorbic! - Updates the sqlite() database adapter to open the database in write-ahead logging (WAL) mode, matching what emdash init, emdash dev, and emdash seed already do. Sites whose database was created or touched by those commands were already running in WAL mode; sites whose database was only ever opened by the running server switch to it on their next start.

    In WAL mode, SQLite keeps -wal and -shm files next to the database (for example data.db-wal and data.db-shm), and the server needs write access to the database directory. If your backup script copies only the .db file while the site is running, switch to sqlite3 data.db ".backup backup.db", or stop the server first and copy all three files.

    Keep the database on a local disk or block volume: WAL does not work on network filesystems such as NFS or SMB.

Patch Changes

  • #3354 f58b8f6 Thanks @swissky! - Fixes entries that could not be saved because a rich text, multi-select, or repeater field held a blank string, typically left behind by an import. Saving failed with expected array, received string on a field the editor never touched. Blank or whitespace-only values for these fields are now stored as empty (null) when sent through the admin, the content API, or MCP, so such entries save normally and are repaired the next time they are saved from the admin. Blank values in required fields are still rejected as missing.

  • #3017 5a9d822 Thanks @jakevis! - Fixes email-verification signup, which could not be completed: the verification email linked to the JSON API endpoint instead of the signup page, the signup page itself redirected anonymous visitors to login, and that redirect dropped the ?token= from the URL. The email now links to /_emdash/admin/signup?token=… (as the invite email already did), the page is reachable without a session, and the login redirect preserves the query string of the page it returns to.

  • #3241 b8fae35 Thanks @swissky! - Fixes the General and SEO settings screens so removing the site logo, favicon, or default social image remains cleared after saving. REST, MCP, and setSiteSettings() callers can remove these media references by setting them to null; omitted settings remain unchanged.

  • #3400 678b848 Thanks @danielmlr! - Fixes new comments waiting for review with the reason "No moderator configured" on a site with one comment moderation plugin. When no comment:moderate choice is stored and exactly one plugin provides the hook, EmDash selects that plugin over the built-in moderator. EmDash no longer stores the built-in moderator as the choice when it is the only moderator, so a moderation plugin added later takes over.

    A choice stored by an earlier release is kept, which on most existing sites is the built-in moderator. The admin route PUT /_emdash/api/admin/hooks/exclusive/comment:moderate currently changes a stored choice.

  • #3382 1b1d443 Thanks @swissky! - Enforces the public comment submission rate limit under concurrent requests. Parallel submissions could previously all pass the check before any of them was saved, so a burst could post far more than the allowed 5 comments per 10 minutes per IP address (or 20 in total, shared by all visitors, when the site cannot determine a trusted client IP). The limit now resets at fixed 10-minute boundaries, so up to twice the cap can be accepted in quick succession across a boundary. On sites with Turnstile enabled, only submissions that pass the CAPTCHA count toward the limit. Submissions that pass the rate-limit check but are rejected later, for example by a comment:beforeCreate hook, now also count toward the limit. Rate-limited responses now include a Retry-After header.

  • #3358 043960e Thanks @swissky! - Fixes GET /_emdash/api/content/{collection}?status=all returning an empty list. status=all now lists entries of every status, the same as omitting status. The endpoint, EmDashClient.list(), and emdash content list --status now reject any other value outside draft, published, scheduled, archived, pending, private, and future with a 400 VALIDATION_ERROR instead of silently returning no items. Clients that sent a mistyped or unsupported status should send one of these values or omit status.

  • #3390 f3f7cc3 Thanks @swissky! - Fixes plugin cron schedules (ctx.cron.schedule() with @daily, @hourly, or standard cron expressions) so they resolve in UTC on every host. Node self-hosts previously resolved them in the server's TZ, so the same schedule fired at a different time than on Cloudflare Workers, which always runs in UTC.

    Node deployments whose TZ is not UTC will see recurring plugin tasks move after upgrading: 0 2 * * * now runs at 02:00 UTC instead of 02:00 local time, and schedules no longer shift with daylight saving time. Each task switches the next time it runs or is rescheduled. Cloudflare Workers deployments and one-shot ISO 8601 schedules are unchanged. Plugin authors who need a local wall-clock time should write the expression in UTC; host-timezone resolution cannot be restored.

  • #3361 5eaf095 Thanks @swissky! - Fixes definePlugin() silently producing a plugin with no hooks when given a plugin descriptor, such as the result of cloudflareEmail({...}). The wrapped plugin registered normally but did nothing, so a wrapped email provider left the site with no email delivery. definePlugin() now throws an error that names the plugin, so a site that still wraps a descriptor fails to start until the wrapper is removed. Pass the descriptor directly to the plugins array of the emdash() integration instead:

    // Before: a local plugin module whose createPlugin() returns
    // definePlugin(cloudflareEmail({ from: "cms@mails.example.com" }))
    
    // After, in astro.config.mjs
    emdash({
    	plugins: [cloudflareEmail({ from: "cms@mails.example.com" })],
    });
  • #3300 2e8e063 Thanks @merrickma! - Fixes a D1/SQLite performance regression where taxonomy-filtered listings (e.g. where: { category: "News" } ordered by published_at or created_at) read orders of magnitude more rows than necessary. The picked CTE now uses a plain JOIN so the planner can drive from the content table's deleted-sort index, probe the pivot by primary key, and short-circuit at LIMIT, restoring the indexed-sort early-limit behavior.

  • #3359 9a5d5b9 Thanks @swissky! - Fixes every admin page (including login and the setup wizard) returning an empty response when fonts: false is set in the emdash() integration config. The admin now falls back to system fonts.

  • #3151 08e93b8 Thanks @emdashbot! - Fix cloudflareEmail() silently failing to reach binding.send() on some Astro Cloudflare builds, and make email delivery errors visible in runtime logs.

    • The provider now resolves cloudflare:workers env through a dedicated helper module (cloudflare-email-env.ts) so the Worker runtime evaluates it as a static import rather than a direct dynamic import of the built-in specifier. This avoids bundler/runtime paths where import("cloudflare:workers") inside the bundled Worker did not resolve.
    • EmailPipeline.send() now console.errors the provider and recipient before re-throwing a delivery error, so failures are not lost when callers (e.g. magic-link routes) intentionally swallow the error to avoid leaking whether an account exists.

    No configuration changes are required; existing send_email bindings continue to work.

  • #3348 6f1b046 Thanks @swissky! - Fixes gallery blocks seeded with $media showing empty images and losing their media on first edit. The Gallery component now renders these images, including galleries seeded with earlier versions, and the content editor previews them and keeps their media references, alt text, and dimensions when it saves. Seeding a gallery now stores each $media image as a regular gallery media reference. Galleries whose references an earlier autosave already stripped are not restored.

  • #3398 7df822b Thanks @danielmlr! - Fixes Portable Text image blocks seeded with $media, including those seeded with earlier versions, rendering with an empty src and losing their media reference when first edited in the admin or through visual editing. Seeding now stores the image as a regular media reference with its alt text and dimensions on the block, the same shape the editor saves. Blocks whose media reference an earlier edit already removed need their image selected again.

  • #3350 21ee693 Thanks @swissky! - Fixes saving from visual editing changing custom blocks identified by url, such as embeds imported from WordPress, to use id, which dropped their url. Blocks now keep the identity field they were stored with: blocks with both id and url keep both, and blocks with neither no longer gain an empty id. Custom blocks inserted in the content editor no longer gain an empty id when no ID is entered.

  • #3438 c78a6cb Thanks @swissky! - Fixes upgrades that hang or fail on the 035_bounded_404_log migration when _emdash_404_log holds many rows, on PostgreSQL, SQLite, and Cloudflare D1. Deduplicating the 404 log slowed down sharply as the log grew, so a log with a few hundred thousand entries never finished, held the migration lock, and on PostgreSQL could run the server out of memory. The migration now takes about as long as reading the log once, so sites no longer need to empty the 404 log before upgrading.

    Sites that already failed on this migration

    On SQLite and D1, a site whose earlier attempt stopped partway kept failing on every start with a unique-constraint error on _emdash_404_log. The migration now finishes deduplicating on the next start. If startup instead reports that the migration lock is held, release the stuck migration lock first. PostgreSQL sites were rolled back to their pre-migration state and simply complete on the next start.

  • #3338 ecef5a9 Thanks @khoinguyenpham04! - Fixes cached published pages remaining stale when tag or category assignments change. Assignments still save immediately, and the editor clarifies that term changes do not wait for Publish changes.

  • #3337 ad1465d Thanks @swissky! - Fixes magic link and account recovery emails failing for recipients whose mail is scanned (for example by Microsoft 365 Safe Links). Opening the link now shows a confirmation page in the admin, and the one-time link is only used when the recipient presses Continue, so a scanner that fetches the link no longer uses it up or receives the session. Links in emails sent before the upgrade keep working.

    GET /_emdash/api/auth/magic-link/verify no longer signs in; it redirects to the confirmation page. Scripts that signed in by requesting that URL must now send POST /_emdash/api/auth/magic-link/verify with a JSON body { "token": "..." } and the X-EmDash-Request: 1 header, then keep the returned session cookie.

  • #3416 f832fe9 Thanks @swissky! - Fixes marketplace plugin updates installing a version that failed or did not clear the marketplace security audit. Updating to a version whose audit verdict is fail or warn, whether it is the latest version or one chosen explicitly, is now refused with the same AUDIT_FAILED error as installing it, and the installed version stays in place.

  • #3433 4a5241a Thanks @swissky! - Fixes the MCP content_duplicate tool letting contributors and authors copy content owned by other users. The tool now requires permission to edit the source item, matching the REST duplicate endpoint: authors can duplicate their own content, editors and above can duplicate anyone's, and contributors can no longer duplicate content through MCP. The copy is now attributed to the user who made it instead of the source item's author.

  • #3067 2db6c98 Thanks @swissky! - Media deletion no longer leaves files behind. The MCP media_delete tool and the plugin ctx.media.delete() API now remove the stored file as well as the record, matching the admin API. When the storage delete fails, DELETE /_emdash/api/media/:id reports storageDeleted: false instead of a plain success, and the periodic cleanup retries the file deletion until it succeeds.

  • #3413 e9b70ec Thanks @danielmlr! - Fixes the Media Library Used in tab reporting no usage for a file selected as the site logo, favicon, or default social image. Those settings appear as a Site Settings result, GET /_emdash/api/media/{id}/usage lists them in a new siteSettings array, and usage.count includes them. An empty Used in tab now says "No tracked references found" and names what is not checked, such as custom rich text blocks, instead of stating that the file is not used in any content.

  • #3395 14e9fdd Thanks @danielmlr! - Fixes astro build failing with Rollup failed to resolve import "cloudflare:sockets" on Astro 6 sites that use @astrojs/node or another non-Cloudflare adapter, so EmDash's own import no longer needs a vite.build.rollupOptions.external: [/^cloudflare:/] workaround.

  • #3386 931b40d Thanks @swissky! - Fixes passkey sign-in options revealing whether an email address has an account. POST /_emdash/api/auth/passkey/options now ignores the optional email field and returns the same options for every request, so the browser offers any passkey saved for the site. The default admin login is unaffected. Clients that posted email to this endpoint to sign in with passkeys not stored on the authenticator (non-discoverable credentials, such as some older security keys) can no longer sign in with those keys. Register a passkey on an authenticator that supports discoverable credentials (most platform authenticators and current security keys), or sign in with a magic link or a configured OAuth provider. The @emdash-cms/admin PasskeyLogin component's showEmailInput prop is deprecated and no longer shows an email field; existing callers still type-check and can drop the prop. The previous email-scoped behavior cannot be restored.

  • #3419 19488ec Thanks @khoinguyenpham04! - Fixes click-to-edit for text on the page in Edit mode:

    • In the Playground, clicking an editable title or text field now edits it in place, and clicking an editable image opens the image picker. Previously these fields showed an outline on hover but did nothing when clicked.
    • Multi-line text fields, such as excerpts, now edit in place when the page shows the field's stored text unchanged, and line breaks added while editing are saved. Previously, clicking a text field always opened the entry in the admin. It still does when the page shows the text transformed, for example Markdown rendered as HTML or a shortened excerpt, so an edit on the page can't overwrite the full stored text.
    • Clicking an editable field inside a link, such as a title in a post card, edits the field instead of following the link, including when the field is clicked before the editor has finished loading.
  • #1745 6ebd4ef Thanks @swissky! - Fixes preview links 404ing on sites with a custom collection url_pattern. The content Preview button now resolves the collection's url_pattern (the same route the sitemap and "View published" links use) instead of the hard-coded /{collection}/{id}, falling back to /{collection}/{id} only when no pattern is configured. An explicit pathPattern or EMDASH_PREVIEW_PATH_PATTERN still takes precedence.

  • #3403 b2f6c06 Thanks @danielmlr! - Fixes registry plugin installs and updates on Node.js sites, along with the plugin icons, banners, and screenshots on the admin's registry plugin page. Since 0.36.0, every registry download on Node.js failed, and installs stopped with errors such as The publisher DID document could not be fetched. Sites on Cloudflare Workers were not affected.

  • #3399 e4b0d81 Thanks @danielmlr! - Fixes the admin plugin registry showing "Handle unavailable" for every publisher and scrolling sideways, so verified publisher handles now appear and unresolved publisher identifiers stay inside their cards.

    A publisher whose handle no longer resolves back to its account now shows INVALID HANDLE, and installing its plugins from the registry detail page is disabled until the publisher fixes the handle. Plugins that are already installed keep running.

  • #3410 baf3010 Thanks @danielmlr! - Reduces the D1 queries needed to apply a seed with sample content, such as through the setup wizard's Include sample content option, from 22 to 10 per published entry with plain fields, leaving more of a Worker invocation's D1 query limit for larger seeds.

  • #3355 b804977 Thanks @swissky! - Fixes seeds that declare category or tag losing their labels and collection assignments. Every new database starts with built-in versions of these two taxonomy definitions, and seeding skipped them as existing. Until a site edits them, the built-in definitions are now replaced by the seed's in every onConflict mode, including --on-conflict error, which no longer fails on them. Edited definitions are still kept by the default skip mode. Running emdash seed against an existing site that never edited these two taxonomies now applies the seed's labels, collections, and hierarchy to them. applySeed() results include taxonomies.skipped, emdash seed prints it, and the automatic first-boot seed logs a warning when it keeps any existing taxonomy definitions.

  • #3401 4028ee4 Thanks @danielmlr! - Fixes kvCache() rate-limit (429) errors, logged as [object-cache] epoch bump failed, when a seed with sample content is applied on Cloudflare Workers, such as through the setup wizard's Include sample content option. Applying a seed now writes each invalidated cache key to KV once, when the seed finishes, instead of once per entry.

  • #3356 90ed23f Thanks @swissky! - Fixes public pages redirecting visitors and crawlers to /_emdash/admin/setup when the database has never been migrated. With the default automatic migration mode, the first public request now migrates the database and renders the page. If that fails, the page answers with a temporary uncached 503 that links to the setup wizard. Opening /_emdash/admin on a new site still leads to the setup wizard.

  • #3357 66f50dd Thanks @swissky! - Fixes anonymous public page requests making a second database connection attempt after the setup check on a fresh server instance has already failed to reach the database. The request now skips CMS runtime initialization and renders without plugin-contributed metadata and page fragments, as it already did when runtime initialization failed. Later requests retry the check, and a server instance whose runtime is already running keeps using it.

  • #3368 4a69cc6 Thanks @danielmlr! - Fixes term translations that could sit under a different parent than the term they translate. A translation created without a parent through the REST API, MCP taxonomy_create_term, the plugin createTerm() API, a seed file or a WordPress import now takes its term's parent and position instead of becoming a root term in its locale; sending parentId: null does the same as leaving it out. A translation created under a different parent moves the term in every locale, as changing the parent through any locale already did.

    On sites where earlier versions left a term's locales under different parents, saving the term's parent from any locale now moves every locale under it. The admin's term dialog sends the current parent on every save, so the first save of such a nested term, even a label change, settles its parent for all locales.

  • #3380 20858ed Thanks @swissky! - Malformed invite, signup, and magic-link tokens now return a clean "invalid token" error instead of a 500. Token hashing tolerates values that aren't valid base64url, so an unrecognized token misses the lookup like any other unknown token.

  • #3405 d9f0d85 Thanks @swissky! - Fixes ctx.media.upload() in trusted (in-process) plugins accepting any content type and keeping the filename's extension, which let a public plugin route, such as the forms plugin's submit endpoint, store files like HTML pages that the site then served under their own type. Trusted plugins can now upload through ctx.media.upload() or request an upload URL through ctx.media.getUploadUrl() only for the types in the default media upload allowlist: PNG, JPEG, GIF, WebP, and AVIF images, video, audio, and PDF. Any other type, including SVG and application/octet-stream, throws a PluginRouteError with status 415 UNSUPPORTED_MEDIA_TYPE, and a malformed content type throws one with status 400. A file stored by upload() or reserved by getUploadUrl() gets an extension that matches its content type, so an upload named page.html with type image/png is stored and served as a PNG; when the type has no known extension, the filename's extension is kept only if it belongs to an allowed media type. Trusted plugins that uploaded other types must switch to one of the accepted types.

  • #2889 86a33ee Thanks @amasen02! - Generated emdash-env.d.ts files now include entry.data.byline?: BylineSummary | null alongside bylines, matching the byline data hydrated by getEmDashEntry and getEmDashCollection at runtime.

  • #3397 b5ad2fe Thanks @danielmlr! - Fixes the image popover in the visual editing toolbar: uploads no longer report "Save failed" and leave an unused copy in the media library on each attempt, the media library lists your images instead of "No images found", and alt text edits on an existing image are saved.

  • #3051 ff0ef63 Thanks @logelog! - Fixes Workerd plugins retaining access after they are disabled or unloaded, and keeps capabilities, allowed hosts, and storage declarations scoped to the loaded plugin version. Re-enabling a plugin issues fresh credentials.

  • Updated dependencies [973699a, 5a9d822, c99bcd3, b8fae35, 1f193b2, 840a9d3, 6f1b046, ed51c68, 7df822b, 21ee693, 28dec10, 1217386, ecef5a9, bf6b0a9, ad1465d, e9b70ec, 931b40d, a30b110, 7a4e1fd, e4b0d81, 1796cd5, 1796cd5, a733b90, 20858ed]:

    • @emdash-cms/auth@0.40.0
    • @emdash-cms/admin@0.40.0
    • @emdash-cms/gutenberg-to-portable-text@0.40.0
    • @emdash-cms/blocks@0.40.0

Don't miss a new emdash release

NewReleases is sending notifications on new releases.