Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-content-update-locale.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ export async function handleContentUpdate(
status?: string;
authorId?: string | null;
bylines?: ContentBylineInput[];
locale?: string;
_rev?: string;
seo?: ContentSeoInput;
publishedAt?: string | null;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/api/openapi/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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",
Expand Down Expand Up @@ -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,
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/cli/commands/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ export class EmDashClient {
slug?: string;
status?: string;
_rev?: string;
locale?: string;
},
): Promise<ContentItem> {
// Convert markdown strings to PT
Expand All @@ -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,
);

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2272,6 +2272,7 @@ export class EmDashRuntime {
noIndex?: boolean;
};
publishedAt?: string | null;
locale?: string;
/** Skip revision creation (used by autosave) */
skipRevision?: boolean;
_rev?: string;
Expand All @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions packages/core/tests/integration/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions packages/core/tests/integration/mcp/content-misc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions packages/core/tests/unit/api/content-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions packages/core/tests/unit/api/content-route-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof updateContent>[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",
}),
);
});
});
});
Loading
Loading