diff --git a/.changeset/fix-content-update-locale.md b/.changeset/fix-content-update-locale.md new file mode 100644 index 0000000000..aee2c7e5aa --- /dev/null +++ b/.changeset/fix-content-update-locale.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes locale-aware content updates so REST, CLI, client, and MCP callers can safely update content by slug when multiple locales share the same slug. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 2e0a3897e3..628af4548f 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -695,6 +695,7 @@ export async function handleContentUpdate( status?: string; authorId?: string | null; bylines?: ContentBylineInput[]; + locale?: string; _rev?: string; seo?: ContentSeoInput; publishedAt?: string | null; @@ -722,7 +723,7 @@ export async function handleContentUpdate( const repo = new ContentRepository(db); // Resolve slug → ID if needed - const resolvedId = (await resolveId(repo, collection, id)) ?? id; + const resolvedId = (await resolveId(repo, collection, id, body.locale)) ?? id; // Wrap content + SEO writes in a transaction for atomicity. // The _rev check is inside the transaction so the read-then-write diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index 44b7147d56..ea9ecef550 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -257,6 +257,9 @@ const contentPaths = { collection: z.string().meta({ description: "Collection slug" }), id: z.string().meta({ description: "Content ID or slug" }), }), + query: z.object({ + locale: z.string().optional().meta({ description: "Locale filter" }), + }), }, requestBody: { content: { [JSON_CONTENT]: { schema: contentUpdateBody } }, diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id].ts b/packages/core/src/astro/routes/api/content/[collection]/[id].ts index e253c312f5..e17b16bced 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id].ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id].ts @@ -68,6 +68,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; + const locale = new URL(request.url).searchParams.get("locale") || undefined; const body = await parseBody(request, contentUpdateBody); if (isParseError(body)) return body; @@ -76,7 +77,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => { } // Fetch item to check ownership - const existing = await emdash.handleContentGet(collection, id); + const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) { return apiError( existing.error?.code ?? "UNKNOWN_ERROR", @@ -120,6 +121,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => { // Pass _rev through for optimistic concurrency validation const result = await emdash.handleContentUpdate(collection, resolvedId, { ...updateBody, + locale, _rev: body._rev, }); diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 3a2f51a8d1..d29164668f 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -274,6 +274,7 @@ export interface EmDashHandlers { status?: string; authorId?: string | null; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; + locale?: string; seo?: { title?: string | null; description?: string | null; diff --git a/packages/core/src/cli/commands/content.ts b/packages/core/src/cli/commands/content.ts index 292b09e042..61ac36fe42 100644 --- a/packages/core/src/cli/commands/content.ts +++ b/packages/core/src/cli/commands/content.ts @@ -233,6 +233,7 @@ const updateCommand = defineCommand({ description: "Revision token from get (prevents overwriting unseen changes)", required: true, }, + locale: { type: "string", description: "Locale for slug resolution" }, draft: { type: "boolean", description: "Keep as draft instead of auto-publishing", @@ -247,17 +248,18 @@ const updateCommand = defineCommand({ const updated = await client.update(args.collection, args.id, { data, _rev: args.rev, + locale: args.locale, }); // Auto-publish unless --draft is set. // Only publish if the update created a draft revision (i.e. the // collection supports revisions and data went to a draft). if (!args.draft && updated.draftRevisionId) { - await client.publish(args.collection, args.id); + await client.publish(args.collection, updated.id); } // Re-fetch to return the current state - const item = await client.get(args.collection, args.id); + const item = await client.get(args.collection, updated.id); output(item, args); } catch (error) { consola.error(error instanceof Error ? error.message : "Unknown error"); diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index aa31f4b85c..7d6d850b05 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -521,6 +521,7 @@ export class EmDashClient { slug?: string; status?: string; _rev?: string; + locale?: string; }, ): Promise { // Convert markdown strings to PT @@ -536,9 +537,11 @@ export class EmDashClient { status: input.status, ...(input._rev ? { _rev: input._rev } : {}), }; + const params = new URLSearchParams(); + if (input.locale) params.set("locale", input.locale); const result = await this.request<{ item: ContentItem; _rev?: string }>( "PUT", - `/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, + `/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}${params.toString() ? `?${params}` : ""}`, body, ); diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index aa095357e9..b32bb52538 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2272,6 +2272,7 @@ export class EmDashRuntime { noIndex?: boolean; }; publishedAt?: string | null; + locale?: string; /** Skip revision creation (used by autosave) */ skipRevision?: boolean; _rev?: string; @@ -2280,7 +2281,7 @@ export class EmDashRuntime { // Resolve slug → ID if needed (before any lookups) const { ContentRepository } = await import("./database/repositories/content.js"); const repo = new ContentRepository(this.db); - const resolvedItem = await repo.findByIdOrSlug(collection, id); + const resolvedItem = await repo.findByIdOrSlug(collection, id, body.locale); const resolvedId = resolvedItem?.id ?? id; // Validate _rev early — before draft revision writes which modify updated_at. diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index e099a98675..50a889c30d 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -632,6 +632,12 @@ export function createMcpServer(): McpServer { inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), + locale: z + .string() + .optional() + .describe( + "Locale to scope slug lookup (e.g. 'fr'). Only affects slug resolution; IDs are globally unique.", + ), data: z .record(z.string(), z.unknown()) .optional() @@ -677,7 +683,7 @@ export function createMcpServer(): McpServer { const { emdash, userId, userRole } = getExtra(extra); // Fetch item to check ownership - const existing = await emdash.handleContentGet(args.collection, args.id); + const existing = await emdash.handleContentGet(args.collection, args.id, args.locale); if (!existing.success) { return unwrap(existing); } @@ -713,6 +719,7 @@ export function createMcpServer(): McpServer { data: args.data, slug: args.slug, authorId: userId, + locale: args.locale, seo: args.seo, bylines: args.bylines, publishedAt: args.publishedAt, @@ -736,6 +743,7 @@ export function createMcpServer(): McpServer { data: args.data, slug: args.slug, authorId: userId, + locale: args.locale, seo: args.seo, bylines: args.bylines, publishedAt: args.publishedAt, @@ -751,6 +759,7 @@ export function createMcpServer(): McpServer { data: args.data, slug: args.slug, authorId: userId, + locale: args.locale, seo: args.seo, bylines: args.bylines, publishedAt: args.publishedAt, diff --git a/packages/core/tests/integration/cli/cli.test.ts b/packages/core/tests/integration/cli/cli.test.ts index 15cfcd9fa2..69a80030f8 100644 --- a/packages/core/tests/integration/cli/cli.test.ts +++ b/packages/core/tests/integration/cli/cli.test.ts @@ -172,6 +172,76 @@ describe("CLI Integration", () => { await cli("content", "delete", "posts", created.id); }); + it("updates content by slug scoped to locale", async () => { + const slug = "cli-shared-locale"; + const en = await cliJson<{ id: string; data: { title: string } }>( + "content", + "create", + "posts", + "--data", + JSON.stringify({ title: "CLI EN" }), + "--slug", + slug, + "--locale", + "en", + ); + const fr = await cliJson<{ id: string; data: { title: string } }>( + "content", + "create", + "posts", + "--data", + JSON.stringify({ title: "CLI FR" }), + "--slug", + slug, + "--locale", + "fr", + ); + + const currentFr = await cliJson<{ _rev: string }>( + "content", + "get", + "posts", + slug, + "--locale", + "fr", + ); + await cliJson<{ id: string; locale: string; data: { title: string } }>( + "content", + "update", + "posts", + slug, + "--locale", + "fr", + "--rev", + currentFr._rev, + "--data", + JSON.stringify({ title: "CLI FR Updated" }), + ); + + const fetchedEn = await cliJson<{ data: { title: string } }>( + "content", + "get", + "posts", + slug, + "--locale", + "en", + ); + const fetchedFr = await cliJson<{ locale: string; data: { title: string } }>( + "content", + "get", + "posts", + slug, + "--locale", + "fr", + ); + expect(fetchedEn.data.title).toBe("CLI EN"); + expect(fetchedFr.locale).toBe("fr"); + expect(fetchedFr.data.title).toBe("CLI FR Updated"); + + await cli("content", "delete", "posts", en.id); + await cli("content", "delete", "posts", fr.id); + }); + it("publishes and unpublishes content", async () => { const item = await cliJson<{ id: string }>( "content", diff --git a/packages/core/tests/integration/mcp/content-misc.test.ts b/packages/core/tests/integration/mcp/content-misc.test.ts index 2e36e69a97..a62cc887ec 100644 --- a/packages/core/tests/integration/mcp/content-misc.test.ts +++ b/packages/core/tests/integration/mcp/content-misc.test.ts @@ -265,6 +265,51 @@ describe("content_translations + locale", () => { expect(frTitle).toBe("FR"); }); + it("content_update with locale param resolves slug per-locale", async () => { + const slug = "shared-update"; + await harness.client.callTool({ + name: "content_create", + arguments: { collection: "post", data: { title: "EN" }, slug, locale: "en" }, + }); + await harness.client.callTool({ + name: "content_create", + arguments: { collection: "post", data: { title: "FR" }, slug, locale: "fr" }, + }); + + const currentFr = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id: slug, locale: "fr" }, + }); + const rev = extractJson<{ _rev: string }>(currentFr)._rev; + + const updated = await harness.client.callTool({ + name: "content_update", + arguments: { + collection: "post", + id: slug, + locale: "fr", + data: { title: "FR Updated" }, + _rev: rev, + }, + }); + expect(updated.isError, extractText(updated)).toBeFalsy(); + + const en = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id: slug, locale: "en" }, + }); + const fr = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id: slug, locale: "fr" }, + }); + const enItem = extractJson<{ item: { data: { title?: unknown } } }>(en).item; + const frItem = extractJson<{ item: { data: { title?: unknown }; locale: string } }>(fr).item; + + expect(enItem.data.title).toBe("EN"); + expect(frItem.locale).toBe("fr"); + expect(frItem.data.title).toBe("FR Updated"); + }); + it("rejects translationOf pointing to a non-existent item", async () => { const result = await harness.client.callTool({ name: "content_create", diff --git a/packages/core/tests/unit/api/content-handlers.test.ts b/packages/core/tests/unit/api/content-handlers.test.ts index 8cef0b3b94..b2412e8f43 100644 --- a/packages/core/tests/unit/api/content-handlers.test.ts +++ b/packages/core/tests/unit/api/content-handlers.test.ts @@ -217,6 +217,43 @@ describe("Content Handlers — auto-slug generation", () => { }); }); + describe("handleContentUpdate — locale-aware slug resolution", () => { + it("updates the row matching body.locale when the identifier is a shared slug", async () => { + const slug = "shared-locale-update"; + const en = await handleContentCreate(db, "post", { + data: { title: "English" }, + slug, + locale: "en", + }); + const fr = await handleContentCreate(db, "post", { + data: { title: "French" }, + slug, + locale: "fr", + }); + expect(en.success).toBe(true); + expect(fr.success).toBe(true); + + const currentFr = await handleContentGet(db, "post", slug, "fr"); + expect(currentFr.success).toBe(true); + + const updated = await handleContentUpdate(db, "post", slug, { + data: { title: "French Updated" }, + locale: "fr", + _rev: currentFr.data!._rev, + }); + + expect(updated.success).toBe(true); + expect(updated.data?.item.id).toBe(fr.data?.item.id); + expect(updated.data?.item.locale).toBe("fr"); + expect(updated.data?.item.data.title).toBe("French Updated"); + + const fetchedEn = await handleContentGet(db, "post", slug, "en"); + const fetchedFr = await handleContentGet(db, "post", slug, "fr"); + expect(fetchedEn.data?.item.data.title).toBe("English"); + expect(fetchedFr.data?.item.data.title).toBe("French Updated"); + }); + }); + describe("byline hydration and assignment", () => { it("should assign and return bylines on create", async () => { const bylineRepo = new BylineRepository(db); diff --git a/packages/core/tests/unit/api/content-route-permissions.test.ts b/packages/core/tests/unit/api/content-route-permissions.test.ts index 643a3f9af9..05d524952b 100644 --- a/packages/core/tests/unit/api/content-route-permissions.test.ts +++ b/packages/core/tests/unit/api/content-route-permissions.test.ts @@ -275,5 +275,43 @@ describe("content route — publishedAt / createdAt permission gate", () => { expect(response.status).toBe(200); expect(handleContentUpdate).toHaveBeenCalled(); }); + + it("passes locale query through slug ownership lookup and update", async () => { + const handleContentGet = vi.fn().mockResolvedValue({ + success: true, + data: { item: { id: "fr-id", authorId: "user-1" }, _rev: "rev1" }, + }); + const handleContentUpdate = vi.fn().mockResolvedValue({ + success: true, + data: { item: { id: "fr-id" }, _rev: "rev2" }, + }); + + const request = new Request("http://localhost/_emdash/api/content/post/shared?locale=fr", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: { title: "FR" } }), + }); + + const response = await updateContent({ + params: { collection: "post", id: "shared" }, + request, + locals: { + emdash: { handleContentUpdate, handleContentGet }, + user: makeUser(Role.AUTHOR), + }, + cache: makeCache(), + } as Parameters[0]); + + expect(response.status).toBe(200); + expect(handleContentGet).toHaveBeenCalledWith("post", "shared", "fr"); + expect(handleContentUpdate).toHaveBeenCalledWith( + "post", + "fr-id", + expect.objectContaining({ + data: { title: "FR" }, + locale: "fr", + }), + ); + }); }); }); diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts index ac0897d9bf..4efffa4e05 100644 --- a/packages/core/tests/unit/api/openapi.test.ts +++ b/packages/core/tests/unit/api/openapi.test.ts @@ -154,6 +154,18 @@ describe("OpenAPI document generation", () => { expect(itemPath).toHaveProperty("delete"); }); + it("documents locale query parameter on content item update", () => { + const doc = generateOpenApiDocument(); + const itemPath = doc.paths?.["/_emdash/api/content/{collection}/{id}"]; + const updateOp = itemPath?.put as { + parameters?: Array<{ name?: string; in?: string }>; + }; + + expect(updateOp.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "locale", in: "query" })]), + ); + }); + it("generates unique operation IDs for all operations", () => { const doc = generateOpenApiDocument(); const operationIds: string[] = []; diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index 7693dddf0d..40753ced53 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -189,6 +189,46 @@ describe("EmDashClient", () => { expect(updated.data.title).toBe("Updated"); expect(updated._rev).toBe("bmV3cmV2"); }); + + it("update() appends locale for slug resolution", async () => { + const backend = createMockBackend([ + { + method: "GET", + path: "/schema/collections/posts", + handler: () => + jsonResponse({ + item: { + slug: "posts", + label: "Posts", + fields: [{ slug: "title", type: "string", label: "Title" }], + }, + }), + }, + { + method: "PUT", + path: "/content/posts/shared?locale=fr", + handler: () => + jsonResponse({ + item: { id: "fr-id", locale: "fr", data: { title: "French Updated" } }, + _rev: "bmV3cmV2", + }), + }, + ]); + + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const updated = await client.update("posts", "shared", { + data: { title: "French Updated" }, + locale: "fr", + }); + + expect(updated.id).toBe("fr-id"); + expect(updated.locale).toBe("fr"); + }); }); describe("create()", () => {