Skip to content

[plugins] ctx.content.update() cannot write to seo.* fields (no such column: seo) #374

@davidrc

Description

@davidrc

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

  1. 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"],
      };
    }
  2. 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 };
          },
        },
      },
    });
  3. Register the plugin in astro.config.mjs, start the dev server.

  4. 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 '{}'
  5. Observed: SQLite error no such column: seo. The route returns a failure.

  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions