emdash-auto-meta 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcus Bellamy-Shaw
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # emdash-auto-meta
2
+
3
+ An [Emdash CMS](https://emdashcms.com) plugin that lets AI agents (Claude, ChatGPT, etc.) assign taxonomy terms and set SEO metadata when creating content via the [Emdash MCP server](https://docs.emdashcms.com/mcp).
4
+
5
+ ## The Problem
6
+
7
+ The Emdash MCP server is great for creating and updating content, but has two gaps that make it hard to use from an AI agent on mobile or in agentic workflows:
8
+
9
+ 1. **No `content_set_terms` tool.** There is no way for an agent to assign taxonomy terms (categories, tags, etc.) through the MCP server.
10
+ 2. **SEO metadata serialization bug.** The `seo` parameter in `content_update` does not persist correctly. ([Discussion #1070](https://github.com/emdash-cms/emdash/discussions/1070))
11
+
12
+ This plugin works around both gaps by letting the agent embed a small metadata block directly in the post content. The plugin intercepts every save via `content:afterSave`, processes the block, and strips it before the content reaches readers.
13
+
14
+ ## How It Works
15
+
16
+ 1. When creating a post, the agent appends a metadata block to the Portable Text content field:
17
+
18
+ ```
19
+ <!-- ebt-meta:{"categories":["history"],"tags":["dallas"],"seo_title":"My Title"} -->
20
+ ```
21
+
22
+ 2. On `content:afterSave`, this plugin scans all Portable Text fields for the block.
23
+ 3. It resolves taxonomy slugs to database IDs, assigns terms, and sets SEO — all in a single pass.
24
+ 4. It strips the block from the content and saves the cleaned version.
25
+
26
+ The save pipeline sees only the final, clean content. The metadata block is never visible to site visitors.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install emdash-auto-meta
32
+ ```
33
+
34
+ If you're referencing the plugin locally via a `file:` path during development, also run `npm install` inside the plugin directory so Vite can resolve its peer dependency:
35
+
36
+ ```bash
37
+ cd path/to/emdash-auto-meta && npm install
38
+ ```
39
+
40
+ **Requirements:**
41
+
42
+ - Emdash `^0.12.0`
43
+ - Cloudflare Workers (paid plan) — this plugin uses `getDb()` from `emdash/runtime` and must run as a trusted plugin in your Workers environment
44
+ - Must be registered in `plugins: []` (not `sandboxed: []`)
45
+
46
+ ## Setup
47
+
48
+ ```typescript
49
+ // astro.config.mjs
50
+ import { emdashAutoMeta } from "emdash-auto-meta";
51
+
52
+ export default defineConfig({
53
+ integrations: [
54
+ emdash({
55
+ plugins: [
56
+ emdashAutoMeta({
57
+ taxonomyMap: {
58
+ categories: "category", // your Emdash taxonomy name
59
+ tags: "tag",
60
+ regions: "regions",
61
+ eras: "eras",
62
+ },
63
+ }),
64
+ ],
65
+ }),
66
+ ],
67
+ });
68
+ ```
69
+
70
+ ## Metadata Block Format
71
+
72
+ Append this block to the end of any Portable Text content field when creating a post. The agent writes it; the plugin strips it.
73
+
74
+ ```
75
+ <!-- ebt-meta:{
76
+ "categories": ["history-landmarks"],
77
+ "tags": ["dallas-texas", "new-tag"],
78
+ "regions": ["prairies-lakes"],
79
+ "eras": ["the-oil-boom"],
80
+ "seo_title": "Your SEO Title",
81
+ "seo_description": "Your meta description."
82
+ } -->
83
+ ```
84
+
85
+ The block must be a single Portable Text block node containing the raw HTML comment. All fields are optional — include only what you need.
86
+
87
+ ### TypeScript Interface
88
+
89
+ ```typescript
90
+ interface AutoMeta {
91
+ /** Slugs of existing category terms to assign */
92
+ categories?: string[];
93
+ /** Slugs of tag terms to assign (auto-created if autoCreateTags is true) */
94
+ tags?: string[];
95
+ /** Slugs of existing region terms to assign */
96
+ regions?: string[];
97
+ /** Slugs of existing era terms to assign */
98
+ eras?: string[];
99
+ /** SEO title tag */
100
+ seo_title?: string;
101
+ /** SEO meta description */
102
+ seo_description?: string;
103
+ }
104
+ ```
105
+
106
+ ## Configuration
107
+
108
+ ```typescript
109
+ emdashAutoMeta({
110
+ metaPrefix?: string;
111
+ autoCreateTags?: boolean;
112
+ logLevel?: "silent" | "info" | "debug";
113
+ taxonomyMap?: {
114
+ categories?: string;
115
+ tags?: string;
116
+ regions?: string;
117
+ eras?: string;
118
+ };
119
+ })
120
+ ```
121
+
122
+ | Option | Type | Default | Description |
123
+ |---|---|---|---|
124
+ | `metaPrefix` | `string` | `"<!-- ebt-meta:"` | The prefix string that identifies the metadata block. Change this if your agents use a different convention. |
125
+ | `autoCreateTags` | `boolean` | `true` | When `true`, tag slugs that don't exist in the database are created automatically. Set to `false` to require all tags to be pre-created. |
126
+ | `logLevel` | `"silent" \| "info" \| "debug"` | `"info"` | Controls log output. `"debug"` logs every step including successful updates. `"silent"` suppresses everything except errors. |
127
+ | `taxonomyMap.categories` | `string` | `"category"` | The Emdash taxonomy name that maps to the `categories` key in the metadata block. |
128
+ | `taxonomyMap.tags` | `string` | `"tag"` | The Emdash taxonomy name that maps to the `tags` key. |
129
+ | `taxonomyMap.regions` | `string` | `"regions"` | The Emdash taxonomy name that maps to the `regions` key. |
130
+ | `taxonomyMap.eras` | `string` | `"eras"` | The Emdash taxonomy name that maps to the `eras` key. |
131
+
132
+ ### Taxonomy Notes
133
+
134
+ - **`categories`, `regions`, `eras`** — slugs must match terms that already exist in your Emdash database. Unknown slugs are logged as warnings and skipped.
135
+ - **`tags`** — when `autoCreateTags: true` (the default), unknown slugs are created as new terms with a label derived from the slug (`"dallas-texas"` → `"Dallas Texas"`). Set `autoCreateTags: false` to treat tags the same as other taxonomies.
136
+ - Taxonomy names in `taxonomyMap` must match the `name` field in your Emdash seed exactly, not the display label.
137
+
138
+ ### Prompt for Your Agent
139
+
140
+ Add something like this to your agent's system prompt or instructions:
141
+
142
+ ```
143
+ When creating a post in Emdash, append a metadata block as the final
144
+ paragraph of the content field using this exact format:
145
+
146
+ <!-- ebt-meta:{"categories":["slug"],"tags":["slug"],"seo_title":"Title","seo_description":"Description."} -->
147
+
148
+ Only include the fields you have values for. Taxonomy slugs must be
149
+ lowercase and hyphenated (e.g. "history-landmarks", not "History Landmarks").
150
+ ```
151
+
152
+ ## Limitations
153
+
154
+ - **Trusted plugin only.** This plugin uses `getDb()` from `emdash/runtime` to assign taxonomy terms, which requires direct database access. It cannot run in `sandboxed: []` mode.
155
+ - **Cloudflare Workers (paid plan) required.** The trusted plugin mode that provides `getDb()` is only available on Cloudflare Workers with a paid plan.
156
+ - **Media upload not supported in v1.0.** Attaching images via URL is planned for v1.1, pending a reliable solution for server-side image fetching in the Cloudflare Workers environment. For now, upload images manually through the Emdash admin after creating a post.
157
+ - **Metadata block must be in a Portable Text field.** The plugin scans only `_type: "block"` nodes inside array fields. It will not find the block in plain text or other field types.
158
+ - **One metadata block per save.** Only the first block found is processed. If the agent writes multiple blocks, only the first is consumed; the rest remain in the content.
159
+
160
+ ## Contributing
161
+
162
+ This plugin was built as a workaround for two gaps in the Emdash MCP server. If you're interested in seeing native MCP support for taxonomy assignment and SEO metadata, join the discussion:
163
+
164
+ [github.com/emdash-cms/emdash/discussions/1070](https://github.com/emdash-cms/emdash/discussions/1070)
165
+
166
+ Bug reports and pull requests welcome. Please open an issue before submitting a PR for anything beyond a small bug fix.
167
+
168
+ ## License
169
+
170
+ MIT
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "emdash-auto-meta",
3
+ "version": "1.0.0",
4
+ "description": "Emdash CMS plugin that enables AI agents to assign taxonomy terms and set SEO metadata via the content:afterSave hook",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "keywords": [
10
+ "emdash",
11
+ "emdash-plugin",
12
+ "cms",
13
+ "ai-agents",
14
+ "mcp",
15
+ "claude",
16
+ "taxonomy",
17
+ "seo",
18
+ "cloudflare-workers"
19
+ ],
20
+ "author": "Marcus Bellamy-Shaw",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/marcusbellamyshaw-cell/emdash-auto-meta.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/marcusbellamyshaw-cell/emdash-auto-meta/issues"
28
+ },
29
+ "homepage": "https://github.com/marcusbellamyshaw-cell/emdash-auto-meta#readme",
30
+ "peerDependencies": {
31
+ "emdash": "^0.12.0"
32
+ },
33
+ "devDependencies": {
34
+ "emdash": "latest"
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,300 @@
1
+ import { definePlugin, ulid } from "emdash";
2
+ import type { PluginContext, PluginDescriptor, ResolvedPlugin } from "emdash";
3
+ import { getDb } from "emdash/runtime";
4
+
5
+ // ─── Config ──────────────────────────────────────────────────────────────────
6
+
7
+ export interface EmdashAutoMetaConfig {
8
+ /** Prefix string that identifies the metadata block. Default: "<!-- ebt-meta:" */
9
+ metaPrefix?: string;
10
+ /** Auto-create tag terms that don't exist in the database. Default: true */
11
+ autoCreateTags?: boolean;
12
+ /** Logging verbosity. Default: "info" */
13
+ logLevel?: "silent" | "info" | "debug";
14
+ /**
15
+ * Maps the fixed metadata block keys to the actual taxonomy names in your
16
+ * Emdash schema. Use this when your site's taxonomies are named differently
17
+ * from the defaults.
18
+ */
19
+ taxonomyMap?: {
20
+ /** Taxonomy name for the "categories" key. Default: "category" */
21
+ categories?: string;
22
+ /** Taxonomy name for the "tags" key. Default: "tag" */
23
+ tags?: string;
24
+ /** Taxonomy name for the "regions" key. Default: "regions" */
25
+ regions?: string;
26
+ /** Taxonomy name for the "eras" key. Default: "eras" */
27
+ eras?: string;
28
+ };
29
+ }
30
+
31
+ interface ResolvedConfig {
32
+ metaPrefix: string;
33
+ metaPattern: RegExp;
34
+ autoCreateTags: boolean;
35
+ logLevel: "silent" | "info" | "debug";
36
+ taxonomyMap: {
37
+ categories: string;
38
+ tags: string;
39
+ regions: string;
40
+ eras: string;
41
+ };
42
+ }
43
+
44
+ function resolveConfig(config: EmdashAutoMetaConfig): ResolvedConfig {
45
+ const metaPrefix = config.metaPrefix ?? "<!-- ebt-meta:";
46
+ const escapedPrefix = metaPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+ return {
48
+ metaPrefix,
49
+ metaPattern: new RegExp(`${escapedPrefix}(.+?) -->`),
50
+ autoCreateTags: config.autoCreateTags ?? true,
51
+ logLevel: config.logLevel ?? "info",
52
+ taxonomyMap: {
53
+ categories: config.taxonomyMap?.categories ?? "category",
54
+ tags: config.taxonomyMap?.tags ?? "tag",
55
+ regions: config.taxonomyMap?.regions ?? "regions",
56
+ eras: config.taxonomyMap?.eras ?? "eras",
57
+ },
58
+ };
59
+ }
60
+
61
+ // ─── Metadata Schema ─────────────────────────────────────────────────────────
62
+
63
+ export interface AutoMeta {
64
+ categories?: string[];
65
+ tags?: string[];
66
+ regions?: string[];
67
+ eras?: string[];
68
+ seo_title?: string;
69
+ seo_description?: string;
70
+ }
71
+
72
+ // ─── Internal Types ───────────────────────────────────────────────────────────
73
+
74
+ type Db = Awaited<ReturnType<typeof getDb>>;
75
+
76
+ interface Logger {
77
+ debug(msg: string): void;
78
+ info(msg: string): void;
79
+ warn(msg: string): void;
80
+ error(msg: string): void;
81
+ }
82
+
83
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
84
+
85
+ function makeLogger(ctx: PluginContext, level: "silent" | "info" | "debug"): Logger {
86
+ const tag = "[emdash-auto-meta]";
87
+ return {
88
+ debug: (msg) => { if (level === "debug") ctx.log.debug(`${tag} ${msg}`); },
89
+ info: (msg) => { if (level !== "silent") ctx.log.info(`${tag} ${msg}`); },
90
+ warn: (msg) => { if (level !== "silent") ctx.log.warn(`${tag} ${msg}`); },
91
+ error: (msg) => { if (level !== "silent") ctx.log.error(`${tag} ${msg}`); },
92
+ };
93
+ }
94
+
95
+ function extractMeta(
96
+ data: Record<string, unknown>,
97
+ pattern: RegExp,
98
+ ): { meta: AutoMeta; cleanedData: Record<string, unknown> } | null {
99
+ for (const [fieldKey, fieldValue] of Object.entries(data)) {
100
+ if (!Array.isArray(fieldValue)) continue;
101
+ for (let i = 0; i < fieldValue.length; i++) {
102
+ const block = fieldValue[i] as unknown;
103
+ if (!block || typeof block !== "object") continue;
104
+ const blockObj = block as Record<string, unknown>;
105
+ if (blockObj._type !== "block") continue;
106
+ const children = blockObj.children;
107
+ if (!Array.isArray(children)) continue;
108
+ for (const child of children) {
109
+ if (!child || typeof child !== "object") continue;
110
+ const childObj = child as Record<string, unknown>;
111
+ if (typeof childObj.text !== "string") continue;
112
+ const match = childObj.text.match(pattern);
113
+ if (!match?.[1]) continue;
114
+ let meta: AutoMeta;
115
+ try {
116
+ meta = JSON.parse(match[1]) as AutoMeta;
117
+ } catch {
118
+ return null;
119
+ }
120
+ const cleanedArray = (fieldValue as unknown[]).filter((_, idx) => idx !== i);
121
+ return { meta, cleanedData: { ...data, [fieldKey]: cleanedArray } };
122
+ }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ async function resolveTermSlugs(
129
+ db: Db,
130
+ taxonomyName: string,
131
+ slugs: string[],
132
+ autoCreate: boolean,
133
+ log: Logger,
134
+ ): Promise<string[]> {
135
+ if (slugs.length === 0) return [];
136
+ const rows = await db
137
+ .selectFrom("taxonomies")
138
+ .select(["slug", "translation_group"])
139
+ .where("name", "=", taxonomyName)
140
+ .where("slug", "in", slugs)
141
+ .execute();
142
+ const existingBySlug = new Map(rows.map((r) => [r.slug, r.translation_group]));
143
+ const groups: string[] = [];
144
+ for (const slug of slugs) {
145
+ const group = existingBySlug.get(slug);
146
+ if (group) {
147
+ groups.push(group);
148
+ } else if (autoCreate) {
149
+ const id = ulid();
150
+ const label = slug
151
+ .split("-")
152
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
153
+ .join(" ");
154
+ await db
155
+ .insertInto("taxonomies")
156
+ .values({ id, name: taxonomyName, slug, label, parent_id: null, data: null, locale: "en", translation_group: id } as never)
157
+ .execute();
158
+ groups.push(id);
159
+ log.info(`Created "${taxonomyName}" term: ${slug}`);
160
+ } else {
161
+ log.warn(`Term not found: ${taxonomyName}/${slug}`);
162
+ }
163
+ }
164
+ return groups;
165
+ }
166
+
167
+ async function setContentTerms(
168
+ db: Db,
169
+ collection: string,
170
+ entryId: string,
171
+ taxonomyName: string,
172
+ termGroups: string[],
173
+ ): Promise<void> {
174
+ const newSet = new Set(termGroups);
175
+ const current = await db
176
+ .selectFrom("content_taxonomies")
177
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
178
+ .select("content_taxonomies.taxonomy_id")
179
+ .distinct()
180
+ .where("content_taxonomies.collection", "=", collection)
181
+ .where("content_taxonomies.entry_id", "=", entryId)
182
+ .where("taxonomies.name", "=", taxonomyName)
183
+ .execute();
184
+ const currentSet = new Set(current.map((r) => r.taxonomy_id));
185
+ const toRemove = [...currentSet].filter((g) => !newSet.has(g));
186
+ if (toRemove.length > 0) {
187
+ await db
188
+ .deleteFrom("content_taxonomies")
189
+ .where("collection", "=", collection)
190
+ .where("entry_id", "=", entryId)
191
+ .where("taxonomy_id", "in", toRemove)
192
+ .execute();
193
+ }
194
+ const toAdd = [...newSet].filter((g) => !currentSet.has(g));
195
+ if (toAdd.length > 0) {
196
+ await db
197
+ .insertInto("content_taxonomies")
198
+ .values(toAdd.map((taxonomy_id) => ({ collection, entry_id: entryId, taxonomy_id })))
199
+ .onConflict((oc) => oc.doNothing())
200
+ .execute();
201
+ }
202
+ }
203
+
204
+ // ─── Descriptor Factory ───────────────────────────────────────────────────────
205
+
206
+ export function emdashAutoMeta(config: EmdashAutoMetaConfig = {}): PluginDescriptor<EmdashAutoMetaConfig> {
207
+ return {
208
+ id: "emdash-auto-meta",
209
+ version: "1.0.0",
210
+ entrypoint: "emdash-auto-meta",
211
+ options: config,
212
+ capabilities: ["content:write"],
213
+ };
214
+ }
215
+
216
+ // ─── Runtime Factory ──────────────────────────────────────────────────────────
217
+
218
+ export function createPlugin(options: EmdashAutoMetaConfig = {}): ResolvedPlugin {
219
+ const cfg = resolveConfig(options);
220
+
221
+ return definePlugin({
222
+ id: "emdash-auto-meta",
223
+ version: "1.0.0",
224
+ capabilities: ["content:write"],
225
+
226
+ hooks: {
227
+ "content:afterSave": {
228
+ errorPolicy: "continue",
229
+ handler: async (event: never, ctx: PluginContext) => {
230
+ const log = makeLogger(ctx, cfg.logLevel);
231
+ const ev = event as { content: Record<string, unknown>; collection: string };
232
+ const content = ev.content;
233
+ const contentId = typeof content.id === "string" ? content.id : null;
234
+ const contentData = content.data as Record<string, unknown> | null | undefined;
235
+ if (!contentId || !contentData) return;
236
+
237
+ const extracted = extractMeta(contentData, cfg.metaPattern);
238
+ if (!extracted) return;
239
+
240
+ const { meta, cleanedData } = extracted;
241
+ log.info(`Processing ${ev.collection}/${contentId}`);
242
+
243
+ // Build update payload: cleaned body + optional SEO
244
+ const updatePayload: Record<string, unknown> = { ...cleanedData };
245
+ if (meta.seo_title || meta.seo_description) {
246
+ updatePayload["seo"] = {
247
+ title: meta.seo_title ?? "",
248
+ description: meta.seo_description ?? "",
249
+ };
250
+ }
251
+
252
+ // Strip the meta block (and set SEO) in a single content update
253
+ if (ctx.content && "update" in ctx.content) {
254
+ try {
255
+ await (ctx.content as {
256
+ update: (col: string, id: string, data: Record<string, unknown>) => Promise<unknown>;
257
+ }).update(ev.collection, contentId, updatePayload);
258
+ log.debug(`Content updated: ${ev.collection}/${contentId}`);
259
+ } catch (err) {
260
+ log.error(`Content update failed: ${err}`);
261
+ }
262
+ }
263
+
264
+ // Taxonomy assignment — each taxonomy fails independently
265
+ const assignments = [
266
+ { taxName: cfg.taxonomyMap.categories, slugs: meta.categories ?? [], autoCreate: false },
267
+ { taxName: cfg.taxonomyMap.tags, slugs: meta.tags ?? [], autoCreate: cfg.autoCreateTags },
268
+ { taxName: cfg.taxonomyMap.regions, slugs: meta.regions ?? [], autoCreate: false },
269
+ { taxName: cfg.taxonomyMap.eras, slugs: meta.eras ?? [], autoCreate: false },
270
+ ];
271
+
272
+ if (assignments.every((a) => a.slugs.length === 0)) return;
273
+
274
+ let db: Db;
275
+ try {
276
+ db = await getDb();
277
+ } catch (err) {
278
+ log.error(`Could not get DB: ${err}`);
279
+ return;
280
+ }
281
+
282
+ for (const { taxName, slugs, autoCreate } of assignments) {
283
+ if (slugs.length === 0) continue;
284
+ try {
285
+ const groups = await resolveTermSlugs(db, taxName, slugs, autoCreate, log);
286
+ if (groups.length > 0) {
287
+ await setContentTerms(db, ev.collection, contentId, taxName, groups);
288
+ log.info(`Assigned "${taxName}": ${slugs.join(", ")}`);
289
+ }
290
+ } catch (err) {
291
+ log.error(`Taxonomy "${taxName}" failed: ${err}`);
292
+ }
293
+ }
294
+ },
295
+ },
296
+ },
297
+ });
298
+ }
299
+
300
+ export default createPlugin;
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "preserve",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "verbatimModuleSyntax": true,
9
+ "skipLibCheck": true,
10
+ "rootDir": "src"
11
+ },
12
+ "include": ["src"]
13
+ }