Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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;
Comment thread
WellDunDun marked this conversation as resolved.
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