Description
The plugin ctx.content.update(collection, id, data) method fails with SQLite error: no such column: seo when the data argument includes a seo key, because the SEO panel fields are stored in a separate _emdash_seo table, not on the content row.
This means plugins cannot programmatically set the SEO title, meta description, canonical URL, noindex flag, or OG image for a content item. The REST API PUT /api/content/<collection>/<id> DOES accept a top-level seo: {...} field and writes it correctly, but the plugin API does not mirror this behavior.
Impact
This is a blocker for any plugin that wants to programmatically populate the core SEO panel:
- WordPress migration plugins (Yoast/RankMath importers): cannot import the per-post SEO title and meta description from WXR files into the panel users see in the EmDash admin.
- AI-assisted SEO tools: cannot apply generated titles/descriptions without user approval dialogs.
- Content automation plugins: cannot set SEO metadata alongside the content they create.
The only current workaround is to store SEO data in the plugin's own storage collection and inject it at render time via page:metadata (currently also blocked by #118/#119). But this is strictly worse than writing to the core panel: the data is not visible in the admin UI SEO panel, users cannot edit it there, and it doesn't participate in the core sitemap/robots logic.
Suggested fix
Mirror the REST API behavior in ctx.content.update(): if data.seo is present, extract it and write to _emdash_seo via the existing SeoRepository.upsert() method, then write the rest of data to the content row.
// In the content update adapter used by plugins:
async update(collection, id, data) {
const { seo, ...contentFields } = data;
const updated = await contentRepo.update(collection, id, contentFields);
if (seo) {
await seoRepo.upsert(collection, id, seo);
}
return updated;
}
Similarly, ctx.content.get() should return the seo object alongside data so plugins can read the current SEO state (useful for non-destructive updates).
Context
I hit this while building SEOYoda, an SEO plugin for EmDash that includes a Yoast/RankMath migration path. The importer needs to populate the SEO panel with the per-post title and meta description from Yoast meta keys. Currently we have to stash everything in a plugin-local storage collection and wait for #118/#119 to be merged before we can surface the data on public pages. Writing to the SEO panel via the plugin API would let the data show up in the admin UI immediately and participate in the core sitemap/robots logic. Happy to contribute a PR if that would help.
Steps to reproduce
-
Create a standard-format plugin with write:content capability:
// src/index.ts
export function myPlugin() {
return {
id: "my-plugin",
version: "0.1.0",
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
capabilities: ["read:content", "write:content"],
};
}
-
In the sandbox entry, call ctx.content.update() with a seo field:
// src/sandbox-entry.ts
import { definePlugin } from "emdash";
export default definePlugin({
routes: {
"test/update": {
handler: async (_routeCtx, ctx) => {
// Pick any real post id from your site
const contentId = "01KNHJ0ZW21WY99SSQFDWW9G6G";
await ctx.content!.update("posts", contentId, {
seo: {
title: "Custom SEO Title",
description: "A better meta description",
canonical: "https://example.com/canonical",
noIndex: false,
},
});
return { success: true };
},
},
},
});
-
Register the plugin in astro.config.mjs, start the dev server.
-
Call the test route:
curl -b /tmp/cookies "http://localhost:4322/_emdash/api/plugins/my-plugin/test/update" \
-H "Content-Type: application/json" \
-H "X-EmDash-Request: 1" \
-d '{}'
-
Observed: SQLite error no such column: seo. The route returns a failure.
-
Expected: the SEO panel fields should update successfully, the same way they do when submitted via PUT /api/content/posts/<id> from the admin UI.
Environment
- emdash version: 0.1.0
- Node.js version: 22.22.2
- Runtime: Node.js (
@astrojs/node standalone, output: "server")
- OS: macOS
- Plugin format: Standard (config-based loading via
plugins: [] in astro.config.mjs)
Logs / error output
Server log when `ctx.content.update()` is called with a `seo` field:
[plugin:my-plugin] SEOYoda import: item failed (notes-on-simplicity): no such column: seo
The error originates from EmDash attempting to run an UPDATE statement on the `ec_posts` table with `seo` as a column. The `seo` object should instead be routed to `SeoRepository.upsert()` which writes to the separate `_emdash_seo` table.
Description
The plugin
ctx.content.update(collection, id, data)method fails withSQLite error: no such column: seowhen thedataargument includes aseokey, because the SEO panel fields are stored in a separate_emdash_seotable, not on the content row.This means plugins cannot programmatically set the SEO title, meta description, canonical URL, noindex flag, or OG image for a content item. The REST API
PUT /api/content/<collection>/<id>DOES accept a top-levelseo: {...}field and writes it correctly, but the plugin API does not mirror this behavior.Impact
This is a blocker for any plugin that wants to programmatically populate the core SEO panel:
The only current workaround is to store SEO data in the plugin's own storage collection and inject it at render time via
page:metadata(currently also blocked by #118/#119). But this is strictly worse than writing to the core panel: the data is not visible in the admin UI SEO panel, users cannot edit it there, and it doesn't participate in the core sitemap/robots logic.Suggested fix
Mirror the REST API behavior in
ctx.content.update(): ifdata.seois present, extract it and write to_emdash_seovia the existingSeoRepository.upsert()method, then write the rest ofdatato the content row.Similarly,
ctx.content.get()should return theseoobject alongsidedataso plugins can read the current SEO state (useful for non-destructive updates).Context
I hit this while building SEOYoda, an SEO plugin for EmDash that includes a Yoast/RankMath migration path. The importer needs to populate the SEO panel with the per-post title and meta description from Yoast meta keys. Currently we have to stash everything in a plugin-local storage collection and wait for #118/#119 to be merged before we can surface the data on public pages. Writing to the SEO panel via the plugin API would let the data show up in the admin UI immediately and participate in the core sitemap/robots logic. Happy to contribute a PR if that would help.
Steps to reproduce
Create a standard-format plugin with
write:contentcapability:In the sandbox entry, call
ctx.content.update()with aseofield:Register the plugin in
astro.config.mjs, start the dev server.Call the test route:
Observed: SQLite error
no such column: seo. The route returns a failure.Expected: the SEO panel fields should update successfully, the same way they do when submitted via
PUT /api/content/posts/<id>from the admin UI.Environment
@astrojs/nodestandalone,output: "server")plugins: []in astro.config.mjs)Logs / error output