From 615491a3ff86f546b2ea9c0b69ce8585bcf89fe2 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Mon, 1 Jun 2026 21:31:46 +0000 Subject: [PATCH 1/3] fix(admin/core): forward locale query param through content edit route and write handlers (#1242) --- .changeset/fix-admin-locale-edit.md | 5 + .changeset/fix-core-locale-write.md | 5 + .../admin/src/components/ContentEditor.tsx | 1 + packages/admin/src/components/ContentList.tsx | 1 + packages/admin/src/lib/api/content.ts | 78 ++++-- packages/admin/src/router.tsx | 67 +++-- .../routes/api/content/[collection]/[id].ts | 10 +- .../[collection]/[id]/discard-draft.ts | 6 +- .../api/content/[collection]/[id]/publish.ts | 6 +- .../api/content/[collection]/[id]/schedule.ts | 12 +- .../content/[collection]/[id]/unpublish.ts | 6 +- .../api/content-route-permissions.test.ts | 4 + .../unit/astro/content-route-locale.test.ts | 238 ++++++++++++++++++ 13 files changed, 393 insertions(+), 46 deletions(-) create mode 100644 .changeset/fix-admin-locale-edit.md create mode 100644 .changeset/fix-core-locale-write.md create mode 100644 packages/core/tests/unit/astro/content-route-locale.test.ts diff --git a/.changeset/fix-admin-locale-edit.md b/.changeset/fix-admin-locale-edit.md new file mode 100644 index 000000000..a901331b7 --- /dev/null +++ b/.changeset/fix-admin-locale-edit.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Forward `locale` query param through admin content edit route and API client to resolve correct i18n variant for slug-based lookups (#1242) diff --git a/.changeset/fix-core-locale-write.md b/.changeset/fix-core-locale-write.md new file mode 100644 index 000000000..743513214 --- /dev/null +++ b/.changeset/fix-core-locale-write.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Read `?locale=` in content write routes (PUT, DELETE, publish, unpublish, discard-draft, schedule, unschedule) and forward it to `handleContentGet` for locale-aware slug resolution (#1242) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 3f4834b2a..d4aadea19 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1024,6 +1024,7 @@ export function ContentEditor({ navigate({ to: "/content/$collection/$id", params: { collection, id: tr.id }, + search: { locale: tr.locale }, }) } onCreate={onTranslate} diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index cd43ea62c..6122fb0b9 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -621,6 +621,7 @@ function ContentListItem({ { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`); +export async function fetchContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`); const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to fetch content"); return data.item; } @@ -200,8 +207,12 @@ export async function updateContent( collection: string, id: string, input: UpdateContentInput, + options?: { locale?: string }, ): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), @@ -213,8 +224,15 @@ export async function updateContent( /** * Delete content (moves to trash) */ -export async function deleteContent(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, { +export async function deleteContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`, { method: "DELETE", }); if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete content`)); @@ -284,8 +302,12 @@ export async function scheduleContent( collection: string, id: string, scheduledAt: string, + options?: { locale?: string }, ): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule${query}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scheduledAt }), @@ -300,8 +322,15 @@ export async function scheduleContent( /** * Unschedule content (revert to draft) */ -export async function unscheduleContent(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, { +export async function unscheduleContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule${query}`, { method: "DELETE", }); const data = await parseApiResponse<{ item: ContentItem }>( @@ -366,8 +395,15 @@ export async function getPreviewUrl( /** * Publish content - promotes current draft to live */ -export async function publishContent(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish`, { +export async function publishContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish${query}`, { method: "POST", }); const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to publish content"); @@ -377,8 +413,15 @@ export async function publishContent(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish`, { +export async function unpublishContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish${query}`, { method: "POST", }); const data = await parseApiResponse<{ item: ContentItem }>( @@ -391,8 +434,15 @@ export async function unpublishContent(collection: string, id: string): Promise< /** * Discard draft changes - reverts to live version */ -export async function discardDraft(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft`, { +export async function discardDraft( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft${query}`, { method: "POST", }); const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to discard draft"); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 9bc8f2aa1..eba0f5e4b 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -135,9 +135,10 @@ function patchAutosaveQueries( data?: Record; slug?: string; }; + locale?: string; }, ) { - const { collection, id, savedItem, payload } = params; + const { collection, id, savedItem, payload, locale } = params; const draftRevisionId = savedItem.draftRevisionId; if (draftRevisionId) { @@ -162,7 +163,10 @@ function patchAutosaveQueries( }); } - queryClient.setQueryData(["content", collection, id], savedItem); + queryClient.setQueryData( + locale ? ["content", collection, id, { locale }] : ["content", collection, id], + savedItem, + ); } // Create a base root route without Shell for setup @@ -582,6 +586,7 @@ const contentEditRoute = createRoute({ component: ContentEditPage, validateSearch: (search) => ({ ...(typeof search.field === "string" && { field: search.field }), + ...(typeof search.locale === "string" && { locale: search.locale }), }), }); @@ -606,10 +611,12 @@ function ContentEditPage() { }); const i18n = manifest?.i18n; + const activeLocale = i18n ? (searchParams.locale ?? i18n.defaultLocale) : undefined; const { data: rawItem, isLoading } = useQuery({ - queryKey: ["content", collection, id], - queryFn: () => fetchContent(collection, id), + queryKey: ["content", collection, id, { locale: activeLocale }], + queryFn: () => fetchContent(collection, id, { locale: activeLocale }), + enabled: !i18n || !!activeLocale, }); React.useEffect(() => { @@ -725,9 +732,11 @@ function ContentEditPage() { bylines?: BylineCreditInput[]; skipRevision?: boolean; seo?: ContentSeoInput; - }) => updateContent(collection, id, data), + }) => updateContent(collection, id, data, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); // Also invalidate revisions since a new one was created void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); // Invalidate the cached draft revision so stale data doesn't overwrite the form @@ -753,7 +762,13 @@ function ContentEditPage() { data?: Record; slug?: string; bylines?: BylineCreditInput[]; - }) => updateContent(collection, id, { ...data, skipRevision: true }), + }) => + updateContent( + collection, + id, + { ...data, skipRevision: true }, + { locale: rawItem?.locale ?? activeLocale }, + ), onSuccess: (savedItem, variables) => { patchAutosaveQueries(queryClient, { collection, @@ -763,6 +778,7 @@ function ContentEditPage() { data: variables.data, slug: variables.slug, }, + locale: rawItem?.locale ?? activeLocale, }); setLastAutosaveAt(new Date()); // Keep the cache fresh without refetching older server state back into the form @@ -778,9 +794,11 @@ function ContentEditPage() { }); const publishMutation = useMutation({ - mutationFn: () => publishContent(collection, id), + mutationFn: () => publishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Published`, description: t`Content is now live` }); }, @@ -794,9 +812,11 @@ function ContentEditPage() { }); const unpublishMutation = useMutation({ - mutationFn: () => unpublishContent(collection, id), + mutationFn: () => unpublishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Unpublished`, description: t`Content removed from public view` }); }, @@ -810,9 +830,11 @@ function ContentEditPage() { }); const discardDraftMutation = useMutation({ - mutationFn: () => discardDraft(collection, id), + mutationFn: () => discardDraft(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Changes discarded`, @@ -829,9 +851,12 @@ function ContentEditPage() { }); const scheduleMutation = useMutation({ - mutationFn: (scheduledAt: string) => scheduleContent(collection, id, scheduledAt), + mutationFn: (scheduledAt: string) => + scheduleContent(collection, id, scheduledAt, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); toastManager.add({ title: t`Scheduled`, description: t`Content has been scheduled for publishing`, @@ -847,9 +872,12 @@ function ContentEditPage() { }); const unscheduleMutation = useMutation({ - mutationFn: () => unscheduleContent(collection, id), + mutationFn: () => + unscheduleContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); toastManager.add({ title: t`Unscheduled`, description: t`Content reverted to draft`, @@ -879,6 +907,7 @@ function ContentEditPage() { void navigate({ to: "/content/$collection/$id", params: { collection, id: result.id }, + search: { locale: result.locale }, }); toastManager.add({ title: t`Translation created`, @@ -895,14 +924,14 @@ function ContentEditPage() { }); const deleteMutation = useMutation({ - mutationFn: () => deleteContent(collection, id), + mutationFn: () => deleteContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] }); void navigate({ to: "/content/$collection", params: { collection }, - search: { locale: undefined }, + search: { locale: activeLocale }, }); }, onError: (error) => { 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 e17b16bce..49b4b388a 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id].ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id].ts @@ -64,7 +64,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => { return unwrapResult(result); }; -export const PUT: APIRoute = async ({ params, request, locals, cache }) => { +export const PUT: APIRoute = async ({ params, request, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -76,6 +76,8 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // Fetch item to check ownership const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) { @@ -132,7 +134,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => { return unwrapResult(result); }; -export const DELETE: APIRoute = async ({ params, locals, cache }) => { +export const DELETE: APIRoute = async ({ params, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -141,8 +143,10 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // 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", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts index 321d7d21e..8433a60ae 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts @@ -11,7 +11,7 @@ import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js"; export const prerender = false; -export const POST: APIRoute = async ({ params, locals, cache }) => { +export const POST: APIRoute = async ({ params, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -20,8 +20,10 @@ export const POST: APIRoute = async ({ params, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // 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", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/publish.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/publish.ts index 8a80e6c6e..636e5f64a 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/publish.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/publish.ts @@ -20,7 +20,7 @@ import { contentPublishBody } from "#api/schemas.js"; export const prerender = false; -export const POST: APIRoute = async ({ params, request, locals, cache }) => { +export const POST: APIRoute = async ({ params, request, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -34,8 +34,10 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => { const body = await parseOptionalBody(request, contentPublishBody, {}); if (isParseError(body)) return body; + const locale = url.searchParams.get("locale") || undefined; + // 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", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/schedule.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/schedule.ts index 74a0dd5aa..e833a826b 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/schedule.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/schedule.ts @@ -34,7 +34,7 @@ function extractOwnership(data: unknown): { authorId: string; resolvedId: string }; } -export const POST: APIRoute = async ({ params, request, locals, cache }) => { +export const POST: APIRoute = async ({ params, request, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -45,8 +45,10 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // 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", @@ -68,7 +70,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => { return unwrapResult(result); }; -export const DELETE: APIRoute = async ({ params, locals, cache }) => { +export const DELETE: APIRoute = async ({ params, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -77,8 +79,10 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // 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", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/unpublish.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/unpublish.ts index 1092612e2..5f62e0652 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/unpublish.ts @@ -11,7 +11,7 @@ import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js"; export const prerender = false; -export const POST: APIRoute = async ({ params, locals, cache }) => { +export const POST: APIRoute = async ({ params, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -20,8 +20,10 @@ export const POST: APIRoute = async ({ params, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // 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", 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 05d524952..be21f3d64 100644 --- a/packages/core/tests/unit/api/content-route-permissions.test.ts +++ b/packages/core/tests/unit/api/content-route-permissions.test.ts @@ -180,6 +180,7 @@ describe("content route — publishedAt / createdAt permission gate", () => { const response = await updateContent({ params: { collection: "post", id: "c1" }, request, + url: new URL(request.url), locals: { emdash: { handleContentUpdate, handleContentGet }, user: makeUser(Role.AUTHOR), @@ -204,6 +205,7 @@ describe("content route — publishedAt / createdAt permission gate", () => { const response = await updateContent({ params: { collection: "post", id: "c1" }, request, + url: new URL(request.url), locals: { emdash: { handleContentUpdate, handleContentGet }, user: makeUser(Role.AUTHOR), @@ -234,6 +236,7 @@ describe("content route — publishedAt / createdAt permission gate", () => { const response = await updateContent({ params: { collection: "post", id: "c1" }, request, + url: new URL(request.url), locals: { emdash: { handleContentUpdate, handleContentGet }, user: makeUser(Role.EDITOR), @@ -265,6 +268,7 @@ describe("content route — publishedAt / createdAt permission gate", () => { const response = await updateContent({ params: { collection: "post", id: "c1" }, request, + url: new URL(request.url), locals: { emdash: { handleContentUpdate, handleContentGet }, user: makeUser(Role.AUTHOR), diff --git a/packages/core/tests/unit/astro/content-route-locale.test.ts b/packages/core/tests/unit/astro/content-route-locale.test.ts new file mode 100644 index 000000000..17e33abc1 --- /dev/null +++ b/packages/core/tests/unit/astro/content-route-locale.test.ts @@ -0,0 +1,238 @@ +/** + * Content route locale forwarding. + * + * When a ?locale query param is present, single-item write routes must + * forward it to handleContentGet so slug-based lookups resolve to the + * correct i18n variant instead of returning the first matching row. + */ + +import { Role } from "@emdash-cms/auth"; +import type { APIContext } from "astro"; +import { describe, it, expect, vi } from "vitest"; + +import { + PUT as putItem, + DELETE as deleteItem, +} from "../../../src/astro/routes/api/content/[collection]/[id].js"; +import { POST as postDiscard } from "../../../src/astro/routes/api/content/[collection]/[id]/discard-draft.js"; +import { POST as postPublish } from "../../../src/astro/routes/api/content/[collection]/[id]/publish.js"; +import { + POST as postSchedule, + DELETE as deleteSchedule, +} from "../../../src/astro/routes/api/content/[collection]/[id]/schedule.js"; +import { POST as postUnpublish } from "../../../src/astro/routes/api/content/[collection]/[id]/unpublish.js"; + +const editor = { id: "u-edit", role: Role.EDITOR }; + +function buildEmdash() { + const handleContentGet = vi.fn(async (_collection: string, _id: string, _locale?: string) => ({ + success: true as const, + data: { + item: { + id: "resolved-id", + type: "post", + slug: "hello", + status: "published", + data: {}, + authorId: "u-edit", + primaryBylineId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + publishedAt: "2026-01-01T00:00:00Z", + scheduledAt: null, + liveRevisionId: null, + draftRevisionId: null, + version: 1, + locale: "en", + translationGroup: "tg-1", + }, + _rev: "rev1", + }, + })); + + const handleContentUpdate = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentDelete = vi.fn(async () => ({ + success: true as const, + data: { deleted: true }, + })); + + const handleContentPublish = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentUnpublish = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentDiscardDraft = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentSchedule = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentUnschedule = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + return { + handleContentGet, + handleContentUpdate, + handleContentDelete, + handleContentPublish, + handleContentUnpublish, + handleContentDiscardDraft, + handleContentSchedule, + handleContentUnschedule, + }; +} + +function ctx(opts: { + user: typeof editor; + emdash: ReturnType; + method?: string; + body?: unknown; + url?: string; +}): APIContext { + const url = new URL(opts.url ?? "http://localhost/_emdash/api/content/post/hello"); + const request = new Request(url, { + method: opts.method ?? "GET", + headers: { "content-type": "application/json" }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + return { + params: { collection: "post", id: "hello" }, + url, + request, + locals: { + user: opts.user, + emdash: opts.emdash, + }, + cache: { enabled: false, invalidate: vi.fn() }, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests + } as unknown as APIContext; +} + +describe("PUT /content/:collection/:id forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await putItem( + ctx({ + user: editor, + emdash, + method: "PUT", + body: { data: { title: "Updated" } }, + url: "http://localhost/_emdash/api/content/post/hello?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("DELETE /content/:collection/:id forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await deleteItem( + ctx({ + user: editor, + emdash, + method: "DELETE", + url: "http://localhost/_emdash/api/content/post/hello?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("POST /content/:collection/:id/publish forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await postPublish( + ctx({ + user: editor, + emdash, + method: "POST", + url: "http://localhost/_emdash/api/content/post/hello/publish?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("POST /content/:collection/:id/unpublish forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await postUnpublish( + ctx({ + user: editor, + emdash, + method: "POST", + url: "http://localhost/_emdash/api/content/post/hello/unpublish?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("POST /content/:collection/:id/discard-draft forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await postDiscard( + ctx({ + user: editor, + emdash, + method: "POST", + url: "http://localhost/_emdash/api/content/post/hello/discard-draft?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("POST /content/:collection/:id/schedule forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await postSchedule( + ctx({ + user: editor, + emdash, + method: "POST", + body: { scheduledAt: "2026-12-31T23:59:59Z" }, + url: "http://localhost/_emdash/api/content/post/hello/schedule?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("DELETE /content/:collection/:id/schedule forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await deleteSchedule( + ctx({ + user: editor, + emdash, + method: "DELETE", + url: "http://localhost/_emdash/api/content/post/hello/schedule?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); From 561c99310160a5492ef71a40d7956e69b2842c24 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 3 Jun 2026 20:22:17 +0100 Subject: [PATCH 2/3] fix(admin): carry locale through new-content save navigation The create-new flow navigated to the edit route without a locale search param, while edit links and translation navigation now append one. That asymmetry made the i18n e2e test's full-URL equality assertion fail when navigating back to the default-locale entry. --- packages/admin/src/router.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index eba0f5e4b..28379e37d 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -497,6 +497,7 @@ function ContentNewPage() { void navigate({ to: "/content/$collection/$id", params: { collection, id: result.id }, + search: { locale: result.locale }, }); }, }); From b238b6e8cb177f26ed87a5c2eceb6c1eb87d0869 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 3 Jun 2026 20:59:06 +0100 Subject: [PATCH 3/3] fix(core): resolve locale-update overlap with #1302 after rebase Main's #1302 already added locale reading to the PUT content-update route. Drop this branch's duplicate declaration (kept main's request.url form, which the existing route test exercises) so the merged PUT handler has a single `locale`. The branch's remaining core contribution is locale on DELETE and the publish/unpublish/schedule/ discard-draft sub-action routes. --- .changeset/fix-core-locale-write.md | 2 +- .../core/src/astro/routes/api/content/[collection]/[id].ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.changeset/fix-core-locale-write.md b/.changeset/fix-core-locale-write.md index 743513214..ea65edb1c 100644 --- a/.changeset/fix-core-locale-write.md +++ b/.changeset/fix-core-locale-write.md @@ -2,4 +2,4 @@ "emdash": patch --- -Read `?locale=` in content write routes (PUT, DELETE, publish, unpublish, discard-draft, schedule, unschedule) and forward it to `handleContentGet` for locale-aware slug resolution (#1242) +Read `?locale=` in content write routes (DELETE, publish, unpublish, discard-draft, schedule, unschedule) and forward it to `handleContentGet` for locale-aware slug resolution (#1242) 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 49b4b388a..16374bd63 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id].ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id].ts @@ -64,7 +64,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => { return unwrapResult(result); }; -export const PUT: APIRoute = async ({ params, request, locals, url, cache }) => { +export const PUT: APIRoute = async ({ params, request, locals, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -76,8 +76,6 @@ export const PUT: APIRoute = async ({ params, request, locals, url, cache }) => return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } - const locale = url.searchParams.get("locale") || undefined; - // Fetch item to check ownership const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) {