diff --git a/.changeset/fix-locale-aware-tags.md b/.changeset/fix-locale-aware-tags.md
new file mode 100644
index 000000000..a61e157a8
--- /dev/null
+++ b/.changeset/fix-locale-aware-tags.md
@@ -0,0 +1,6 @@
+---
+"emdash": patch
+"@emdash-cms/admin": patch
+---
+
+Fix taxonomy terms not being locale-aware in the content editor (#1218). Term assignments are stored against the per-locale content row while the term's `translation_group` spans every locale, so resolving terms for an entry must scope to the entry's locale. The content terms endpoint (`/content/:collection/:id/terms/:taxonomy`) now derives the entry's locale server-side and passes it to `getTermsForEntry`, and the admin `TaxonomySidebar` threads the entry locale through its fetch/save calls (and into its React Query keys, so switching translations refetches). Previously a localized post showed and applied every locale variant of a tag instead of just the variant for its own locale.
diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx
index 523ad9e36..0bece59c5 100644
--- a/packages/admin/src/components/ContentEditor.tsx
+++ b/packages/admin/src/components/ContentEditor.tsx
@@ -1031,7 +1031,11 @@ export function ContentEditor({
{/* Taxonomy selector */}
{item && (
-
+
)}
diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx
index cc0335e5e..9d72d1326 100644
--- a/packages/admin/src/components/TaxonomySidebar.tsx
+++ b/packages/admin/src/components/TaxonomySidebar.tsx
@@ -15,7 +15,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js";
-import { createTerm } from "../lib/api/taxonomies.js";
+import { createTerm, withLocale } from "../lib/api/taxonomies.js";
import { termExactMatches, termMatches } from "../lib/taxonomy-match.js";
import { slugify } from "../lib/utils.js";
@@ -40,6 +40,9 @@ interface TaxonomyDef {
interface TaxonomySidebarProps {
collection: string;
entryId?: string;
+ /** Locale of the entry being edited. Scopes term reads/writes so only the
+ * matching translation variants are shown — see issue #1218. */
+ entryLocale?: string;
onChange?: (taxonomyName: string, termIds: string[]) => void;
}
@@ -56,10 +59,11 @@ async function fetchTaxonomyDefs(): Promise {
}
/**
- * Fetch terms for a taxonomy
+ * Fetch terms for a taxonomy, scoped to the entry's locale so only the matching
+ * translation variants are offered.
*/
-async function fetchTerms(taxonomyName: string): Promise {
- const res = await apiFetch(`/_emdash/api/taxonomies/${taxonomyName}/terms`);
+async function fetchTerms(taxonomyName: string, locale?: string): Promise {
+ const res = await apiFetch(withLocale(`/_emdash/api/taxonomies/${taxonomyName}/terms`, locale));
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(
res,
i18n._(msg`Failed to fetch terms`),
@@ -74,8 +78,11 @@ async function fetchEntryTerms(
collection: string,
entryId: string,
taxonomy: string,
+ locale?: string,
): Promise {
- const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`);
+ const res = await apiFetch(
+ withLocale(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, locale),
+ );
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(
res,
i18n._(msg`Failed to fetch entry terms`),
@@ -91,12 +98,16 @@ async function setEntryTerms(
entryId: string,
taxonomy: string,
termIds: string[],
+ locale?: string,
): Promise {
- const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ termIds }),
- });
+ const res = await apiFetch(
+ withLocale(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, locale),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ termIds }),
+ },
+ );
if (!res.ok) await throwResponseError(res, i18n._(msg`Failed to set entry terms`));
}
@@ -277,11 +288,13 @@ function TaxonomySection({
taxonomy,
collection,
entryId,
+ entryLocale,
onChange,
}: {
taxonomy: TaxonomyDef;
collection: string;
entryId?: string;
+ entryLocale?: string;
onChange?: (termIds: string[]) => void;
}) {
const { t } = useLingui();
@@ -291,15 +304,15 @@ function TaxonomySection({
const [showCategoryInput, setShowCategoryInput] = React.useState(false);
const { data: terms = [] } = useQuery({
- queryKey: ["taxonomy-terms", taxonomy.name],
- queryFn: () => fetchTerms(taxonomy.name),
+ queryKey: ["taxonomy-terms", taxonomy.name, entryLocale],
+ queryFn: () => fetchTerms(taxonomy.name, entryLocale),
});
const { data: entryTerms = [] } = useQuery({
- queryKey: ["entry-terms", collection, entryId, taxonomy.name],
+ queryKey: ["entry-terms", collection, entryId, taxonomy.name, entryLocale],
queryFn: () => {
if (!entryId) return [];
- return fetchEntryTerms(collection, entryId, taxonomy.name);
+ return fetchEntryTerms(collection, entryId, taxonomy.name, entryLocale);
},
enabled: !!entryId,
});
@@ -307,11 +320,11 @@ function TaxonomySection({
const saveMutation = useMutation({
mutationFn: (termIds: string[]) => {
if (!entryId) throw new Error("No entry ID");
- return setEntryTerms(collection, entryId, taxonomy.name, termIds);
+ return setEntryTerms(collection, entryId, taxonomy.name, termIds, entryLocale);
},
onSuccess: () => {
void queryClient.invalidateQueries({
- queryKey: ["entry-terms", collection, entryId, taxonomy.name],
+ queryKey: ["entry-terms", collection, entryId, taxonomy.name, entryLocale],
});
toastManager.add({ title: t`${taxonomy.label} updated` });
},
@@ -325,9 +338,17 @@ function TaxonomySection({
});
const createTermMutation = useMutation({
- mutationFn: (label: string) => createTerm(taxonomy.name, { slug: slugify(label), label }),
+ mutationFn: (label: string) =>
+ createTerm(taxonomy.name, {
+ slug: slugify(label),
+ label,
+ // Create the term in the entry's locale so it resolves on this entry.
+ ...(entryLocale ? { locale: entryLocale } : {}),
+ }),
onSuccess: (newTerm) => {
- void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomy.name] });
+ void queryClient.invalidateQueries({
+ queryKey: ["taxonomy-terms", taxonomy.name, entryLocale],
+ });
// Auto-select the newly created term
const newSelected = new Set(selectedIds);
newSelected.add(newTerm.id);
@@ -475,7 +496,12 @@ function TaxonomySection({
/**
* Main TaxonomySidebar component
*/
-export function TaxonomySidebar({ collection, entryId, onChange }: TaxonomySidebarProps) {
+export function TaxonomySidebar({
+ collection,
+ entryId,
+ entryLocale,
+ onChange,
+}: TaxonomySidebarProps) {
const { t } = useLingui();
const { data: taxonomies = [] } = useQuery({
queryKey: ["taxonomy-defs"],
@@ -500,6 +526,7 @@ export function TaxonomySidebar({ collection, entryId, onChange }: TaxonomySideb
taxonomy={taxonomy}
collection={collection}
entryId={entryId}
+ entryLocale={entryLocale}
onChange={(termIds) => onChange?.(taxonomy.name, termIds)}
/>
))}
diff --git a/packages/admin/src/lib/api/taxonomies.ts b/packages/admin/src/lib/api/taxonomies.ts
index 42fc2e699..2c39a9bb2 100644
--- a/packages/admin/src/lib/api/taxonomies.ts
+++ b/packages/admin/src/lib/api/taxonomies.ts
@@ -90,7 +90,7 @@ export interface LocaleOptions {
locale?: string;
}
-function withLocale(path: string, locale?: string): string {
+export function withLocale(path: string, locale?: string): string {
return locale
? `${path}${path.includes("?") ? "&" : "?"}locale=${encodeURIComponent(locale)}`
: path;
diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts
index af76d5ee1..57e57e12a 100644
--- a/packages/core/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts
+++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts
@@ -11,6 +11,7 @@ import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
import { apiError, apiSuccess, handleError, requireDb } from "#api/error.js";
import { parseBody, isParseError } from "#api/parse.js";
import { contentTermsBody } from "#api/schemas.js";
+import { ContentRepository } from "#db/repositories/content.js";
import { TaxonomyRepository } from "#db/repositories/taxonomy.js";
import { invalidateTermCache } from "#taxonomies/index.js";
@@ -34,8 +35,15 @@ export const GET: APIRoute = async ({ params, locals }) => {
if (dbErr) return dbErr;
try {
+ // Terms are stored against the per-locale entry row but their
+ // translation_group spans every locale. Resolve the entry's own locale
+ // server-side (deterministic, not client-spoofable) so only the matching
+ // term variant is returned — see issue #1218.
+ const entry = await new ContentRepository(emdash.db).findByIdOrSlug(collection, id);
+ const locale = entry?.locale ?? undefined;
+
const repo = new TaxonomyRepository(emdash.db);
- const terms = await repo.getTermsForEntry(collection, id, taxonomy);
+ const terms = await repo.getTermsForEntry(collection, id, taxonomy, locale);
return apiSuccess({
terms: terms.map((t) => ({
@@ -101,6 +109,9 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
// Resolve the canonical content ID from the handler result.
// The URL `id` param may be a slug; we must use the real ID for term storage.
const canonicalId = typeof existingItem?.id === "string" ? existingItem.id : id;
+ // The entry is per-locale; scope the term read to its locale so only the
+ // matching translation variant is returned in the response — see #1218.
+ const entryLocale = typeof existingItem?.locale === "string" ? existingItem.locale : undefined;
try {
const body = await parseBody(request, contentTermsBody);
@@ -131,8 +142,8 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
// so hydration on subsequent reads issues a fresh query.
invalidateTermCache();
- // Get the updated terms using the canonical ID
- const terms = await repo.getTermsForEntry(collection, canonicalId, taxonomy);
+ // Get the updated terms using the canonical ID, scoped to the entry locale
+ const terms = await repo.getTermsForEntry(collection, canonicalId, taxonomy, entryLocale);
return apiSuccess({
terms: terms.map((t) => ({
diff --git a/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts b/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts
new file mode 100644
index 000000000..dfb06789a
--- /dev/null
+++ b/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts
@@ -0,0 +1,214 @@
+/**
+ * Locale-aware term resolution for content entries (issue #1218).
+ *
+ * The storage model is correct: `content_taxonomies` stores
+ * `entry_id` = the per-locale content row id and `taxonomy_id` = the term's
+ * `translation_group` (which spans every locale). Resolving the terms for an
+ * entry must therefore scope to the entry's own locale, otherwise EVERY locale
+ * variant of the term is returned.
+ *
+ * The bug was that the admin content-editor terms route
+ * (`/content/:collection/:id/terms/:taxonomy`) never passed a locale, so a
+ * French post showed both the English and French variants of its tag.
+ */
+
+import { Role, type RoleLevel } from "@emdash-cms/auth";
+import type { APIContext } from "astro";
+import type { Kysely } from "kysely";
+import { afterEach, beforeEach, expect, it } from "vitest";
+
+import { handleContentGet } from "../../../src/api/handlers/content.js";
+import {
+ GET as getTerms,
+ POST as postTerms,
+} from "../../../src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].js";
+import { ContentRepository } from "../../../src/database/repositories/content.js";
+import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js";
+import type { Database } from "../../../src/database/types.js";
+import {
+ describeEachDialect,
+ setupForDialectWithCollections,
+ teardownForDialect,
+ type DialectTestContext,
+} from "../../utils/test-db.js";
+
+interface TermFixture {
+ enContentId: string;
+ frContentId: string;
+ enTagId: string;
+ frTagId: string;
+}
+
+async function seedLocalizedTags(db: Kysely): Promise {
+ const contentRepo = new ContentRepository(db);
+ const taxRepo = new TaxonomyRepository(db);
+
+ // Two content rows: EN + FR, same translation group.
+ const enContent = await contentRepo.create({
+ type: "post",
+ slug: "hello",
+ data: { title: "Hello" },
+ locale: "en",
+ });
+ const frContent = await contentRepo.create({
+ type: "post",
+ slug: "bonjour",
+ data: { title: "Bonjour" },
+ locale: "fr",
+ translationOf: enContent.id,
+ });
+
+ // One tag with an EN + FR translation (shared translation_group).
+ const enTag = await taxRepo.create({
+ name: "tags",
+ slug: "news",
+ label: "News",
+ locale: "en",
+ });
+ const frTag = await taxRepo.create({
+ name: "tags",
+ slug: "actualites",
+ label: "Actualités",
+ locale: "fr",
+ translationOf: enTag.id,
+ });
+
+ // Attach the tag (by group) to BOTH entries.
+ await taxRepo.attachToEntry("post", enContent.id, enTag.id);
+ await taxRepo.attachToEntry("post", frContent.id, enTag.id);
+
+ return {
+ enContentId: enContent.id,
+ frContentId: frContent.id,
+ enTagId: enTag.id,
+ frTagId: frTag.id,
+ };
+}
+
+const adminUser = {
+ id: "u-admin",
+ email: "a@example.com",
+ name: "Admin",
+ role: Role.ADMIN as RoleLevel,
+};
+
+function buildGetContext(
+ db: Kysely,
+ params: { collection: string; id: string; taxonomy: string },
+): APIContext {
+ const url = new URL(
+ `http://localhost/_emdash/api/content/${params.collection}/${params.id}/terms/${params.taxonomy}`,
+ );
+ return {
+ params,
+ url,
+ request: new Request(url, { headers: { "X-EmDash-Request": "1" } }),
+ locals: {
+ emdash: {
+ db,
+ handleContentGet: (collection: string, id: string, locale?: string) =>
+ handleContentGet(db, collection, id, locale),
+ },
+ user: adminUser,
+ },
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests
+ } as unknown as APIContext;
+}
+
+function buildPostContext(
+ db: Kysely,
+ params: { collection: string; id: string; taxonomy: string },
+ termIds: string[],
+): APIContext {
+ const url = new URL(
+ `http://localhost/_emdash/api/content/${params.collection}/${params.id}/terms/${params.taxonomy}`,
+ );
+ return {
+ params,
+ url,
+ request: new Request(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
+ body: JSON.stringify({ termIds }),
+ }),
+ locals: {
+ emdash: {
+ db,
+ handleContentGet: (collection: string, id: string, locale?: string) =>
+ handleContentGet(db, collection, id, locale),
+ },
+ user: adminUser,
+ },
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests
+ } as unknown as APIContext;
+}
+
+interface TermsResponse {
+ data?: { terms?: Array<{ id: string; slug: string; label: string }> };
+ error?: { code: string };
+}
+
+describeEachDialect("content terms route locale-awareness (#1218)", (dialect) => {
+ let ctx: DialectTestContext;
+
+ beforeEach(async () => {
+ ctx = await setupForDialectWithCollections(dialect);
+ });
+
+ afterEach(async () => {
+ await teardownForDialect(ctx);
+ });
+
+ it("repository resolves only the entry-locale variant when locale is given", async () => {
+ const fx = await seedLocalizedTags(ctx.db);
+ const taxRepo = new TaxonomyRepository(ctx.db);
+
+ const all = await taxRepo.getTermsForEntry("post", fx.frContentId, "tags");
+ expect(all).toHaveLength(2); // bug surface: both locales without a filter
+
+ const frOnly = await taxRepo.getTermsForEntry("post", fx.frContentId, "tags", "fr");
+ expect(frOnly).toHaveLength(1);
+ expect(frOnly[0]!.id).toBe(fx.frTagId);
+ });
+
+ it("GET returns only the FR variant for the FR entry", async () => {
+ const fx = await seedLocalizedTags(ctx.db);
+
+ const res = await getTerms(
+ buildGetContext(ctx.db, { collection: "post", id: fx.frContentId, taxonomy: "tags" }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as TermsResponse;
+ expect(body.error).toBeUndefined();
+ const ids = (body.data?.terms ?? []).map((t) => t.id);
+ expect(ids).toEqual([fx.frTagId]);
+ });
+
+ it("GET returns only the EN variant for the EN entry", async () => {
+ const fx = await seedLocalizedTags(ctx.db);
+
+ const res = await getTerms(
+ buildGetContext(ctx.db, { collection: "post", id: fx.enContentId, taxonomy: "tags" }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as TermsResponse;
+ const ids = (body.data?.terms ?? []).map((t) => t.id);
+ expect(ids).toEqual([fx.enTagId]);
+ });
+
+ it("POST response echoes only the entry-locale variant", async () => {
+ const fx = await seedLocalizedTags(ctx.db);
+
+ // Re-set the FR entry's tags via the EN term id (resolved to the group).
+ const res = await postTerms(
+ buildPostContext(ctx.db, { collection: "post", id: fx.frContentId, taxonomy: "tags" }, [
+ fx.enTagId,
+ ]),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as TermsResponse;
+ expect(body.error).toBeUndefined();
+ const ids = (body.data?.terms ?? []).map((t) => t.id);
+ expect(ids).toEqual([fx.frTagId]);
+ });
+});