Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
835642e
feat(i18n): add support for menus and taxonomies
Rimander May 5, 2026
11c0732
fix(migration 036): block rollback on multi-locale installs
Rimander May 5, 2026
73cf3f0
test(migration 036): cover data remap and rollback
Rimander May 5, 2026
9cf8370
fix(menus): surface name in NOT_FOUND, detect CONFLICT without locale
Rimander May 5, 2026
f2355d4
fix(seed): set translation_group on menu, item, and taxonomy_def inserts
Rimander May 5, 2026
5f94735
fixup! fix(seed): set translation_group on menu, item, and taxonomy_d…
Rimander May 5, 2026
d6d93a4
style: format
emdashbot[bot] May 5, 2026
a1f6fa5
ci: update query-count snapshots
emdashbot[bot] May 5, 2026
886329a
feat(seed): i18n round-trip for menus and taxonomies
Rimander May 5, 2026
41c8ae6
fix: update migration 036 to resolve locale via i18n config and add r…
Rimander May 5, 2026
101b7d6
style: format
emdashbot[bot] May 5, 2026
94222e3
Merge branch 'main' into feat/i18n-menus-taxonomies
ascorbic May 5, 2026
9c10f45
chore(menus,taxonomies): tidy module conventions
Rimander May 5, 2026
b1ffa39
feat: implement i18n menu cloning and migration refinements for local…
Rimander May 5, 2026
0e4ca37
fix: update migration references to 036 in menus and add documentatio…
Rimander May 5, 2026
b665314
feat: extend featured_image schema with metadata and update e2e test …
Rimander May 5, 2026
31b9ada
ci: update query-count snapshots
emdashbot[bot] May 5, 2026
3ce5bca
test: mock useSearch hook in MenuEditor tests
Rimander May 5, 2026
5c10265
fix(menus): use defaultLocale in CONFLICT guard and document seed ite…
Rimander May 5, 2026
8c2f374
Merge branch 'main' into feat/i18n-menus-taxonomies
ascorbic May 6, 2026
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
43 changes: 43 additions & 0 deletions .changeset/i18n-menus-and-taxonomies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"emdash": minor
---

Adds i18n support to menus and taxonomies (categories, tags, custom
definitions), mirroring the per-locale model already in place for content.
Each row carries `locale` and `translation_group`; translations of the
same menu/term/def share a `translation_group`. `_emdash_menu_items.reference_id`
and `content_taxonomies.taxonomy_id` are remapped to store the referenced
row's translation_group, so a single association survives content
translations and is resolved against the active locale at runtime.

- Runtime helpers (`getMenu`, `getTaxonomyTerms`, `getTerm`, `getEntryTerms`,
`getAllTermsForEntries`, …) accept an optional `{ locale }` and honour the
i18n fallback chain; when no locale is given they fall back to the
request context and `defaultLocale`, matching `getEmDashCollection` /
`getEmDashEntry`.
- REST API: GET endpoints accept `?locale=xx`; POST endpoints accept
`locale` and `translationOf` in their bodies. New endpoints:
`GET/POST /_emdash/api/menus/:name/translations` and
`GET/POST /_emdash/api/taxonomies/:name/terms/:slug/translations`.
- Creating a content translation now auto-copies the source's taxonomy
assignments (the pivot is locale-agnostic, so the copied rows apply to
the whole translation group).
- MCP: `taxonomy_list`, `taxonomy_list_terms`, `taxonomy_create_term`,
`menu_list`, `menu_get` accept `locale`. New tools:
`taxonomy_term_translations`, `menu_translations`.
- Admin: `TaxonomyManager` and `MenuList` surface a `LocaleSwitcher` when
multiple locales are configured and thread the active locale through
all API calls. `TaxonomyManager` exposes a "Translate" action per term
that creates the translation and switches to the new locale.

No breaking changes for new installs or single-locale upgrades — defaults
are additive (locale defaults to `'en'` when omitted, reproducing pre-i18n
behaviour).

> ⚠️ **Rolling back migration `036_i18n_menus_and_taxonomies` is blocked
> on multi-locale installs.** Dropping the `locale` column would collapse
> translated rows onto an ambiguous `(name, slug)` unique key, silently
> deleting content. The migration's `down()` now refuses to run when any
> row uses a non-default locale and prints the affected table in the
> error. If you need to revert, export translations first (or delete
> them), then re-run the rollback. Single-locale installs revert cleanly.
52 changes: 52 additions & 0 deletions docs/src/content/docs/guides/internationalization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,58 @@ Fallback only applies to single-entry queries. List queries return entries for t
When a fallback is used, the response metadata includes `fallbackLocale` so your template can display a "this content is not yet translated" notice.
</Aside>

### Menus

Menus are per-locale — the same `name` (e.g. `"primary"`) can exist in several
locales, all linked via a shared `translation_group`. Menu items resolve their
content references against the active locale's version of the referenced
content.

```astro title="src/components/PrimaryNav.astro"
---
import { getMenu } from "emdash";

const menu = await getMenu("primary", { locale: Astro.currentLocale });
---

<nav aria-label="Primary">
<ul>
{menu?.items.map((item) => (
<li><a href={item.url}>{item.label}</a></li>
))}
</ul>
</nav>
```

Create translations of an existing menu from the admin's **Menus** list — the
items are cloned with `reference_id` intact (it stores the referenced content's
`translation_group`), so the new menu's links point at the right per-locale
content automatically.

### Taxonomies (categories, tags)

Terms are per-locale. Definitions (`_emdash_taxonomy_defs`) are also per-locale,
so `label` / `labelSingular` can be translated too. The pivot
`content_taxonomies.taxonomy_id` stores the term's `translation_group`, so a
single assignment spans every locale of the content.

```astro
---
import { getTaxonomyTerms, getEntryTerms } from "emdash";

const categories = await getTaxonomyTerms("category", {
locale: Astro.currentLocale,
});
const terms = await getEntryTerms("posts", post.id, undefined, {
locale: Astro.currentLocale,
});
---
```

Translating a piece of content automatically inherits the source's term
assignments — you only need to translate the *terms themselves* once, and every
post that uses them resolves to the right locale at read time.

### Collection listing

Filter a collection by locale:
Expand Down
4 changes: 2 additions & 2 deletions e2e/fixtures/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ export class AdminPage {
async clickEditTranslation(locale: string): Promise<void> {
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
await localeRow.getByRole("link", { name: "Edit" }).click();
await localeRow.getByRole("button", { name: "Edit" }).click();
}

/**
Expand All @@ -526,7 +526,7 @@ export class AdminPage {
const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))");
const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`);
return localeRow
.getByRole("link", { name: "Edit" })
.getByRole("button", { name: "Edit" })
.isVisible({ timeout: 3000 })
.catch(() => false);
}
Expand Down
4 changes: 2 additions & 2 deletions e2e/tests/menus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ test.describe("Menus", () => {
// Create menu
await admin.createMenu(menuName, menuLabel);

// Should redirect to menu editor
await expect(page).toHaveURL(new RegExp(`/menus/${menuName}$`));
// Should redirect to menu editor (URL may include ?locale=... in i18n mode)
await expect(page).toHaveURL(new RegExp(`/menus/${menuName}(\\?|$)`));

// Should show the menu label
await expect(page.locator("h1")).toContainText(menuLabel);
Expand Down
66 changes: 16 additions & 50 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
ArrowsOutSimple,
ArrowSquareOut,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import type { Editor } from "@tiptap/react";
import * as React from "react";

Expand Down Expand Up @@ -77,6 +77,7 @@ import { SaveButton } from "./SaveButton";
import { SeoImageField } from "./SeoImageField";
import { SeoPanel } from "./SeoPanel";
import { TaxonomySidebar } from "./TaxonomySidebar";
import { TranslationsPanel } from "./TranslationsPanel.js";

// Editor role level (40) from @emdash-cms/auth
const ROLE_EDITOR = 40;
Expand Down Expand Up @@ -226,6 +227,7 @@ export function ContentEditor({
manifest,
}: ContentEditorProps) {
const { t } = useLingui();
const navigate = useNavigate();
const [formData, setFormData] = React.useState<Record<string, unknown>>(item?.data || {});
const [slug, setSlug] = React.useState(item?.slug || "");
const [slugTouched, setSlugTouched] = React.useState(!!item?.slug);
Expand Down Expand Up @@ -988,55 +990,19 @@ export function ContentEditor({
{/* Translations sidebar - shown when i18n is enabled */}
{i18n && item && !isNew && (
<div className="p-4 border-t">
<h3 className="mb-4 font-semibold">{t`Translations`}</h3>
<div className="space-y-2">
{i18n.locales.map((locale) => {
const translation = translations?.find((tr) => tr.locale === locale);
const isCurrent = locale === item.locale;
return (
<div
key={locale}
className={cn(
"flex items-center justify-between rounded-md px-3 py-2 text-sm",
isCurrent
? "bg-kumo-brand/10 font-medium"
: translation
? "hover:bg-kumo-tint/50"
: "text-kumo-subtle",
)}
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase">{locale}</span>
{locale === i18n.defaultLocale && (
<span className="text-[10px] text-kumo-subtle">{t` (default)`}</span>
)}
{isCurrent && (
<span className="text-[10px] text-kumo-brand">{t`current`}</span>
)}
</div>
{translation && !isCurrent ? (
<Link
to="/content/$collection/$id"
params={{ collection, id: translation.id }}
className="text-xs text-kumo-brand hover:underline"
>
{t`Edit`}
</Link>
) : !translation && onTranslate ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
onClick={() => onTranslate(locale)}
>
{t`Translate`}
</Button>
) : null}
</div>
);
})}
</div>
<TranslationsPanel
locales={i18n.locales}
defaultLocale={i18n.defaultLocale}
currentLocale={item.locale ?? undefined}
translations={translations ?? []}
onOpen={(tr) =>
navigate({
to: "/content/$collection/$id",
params: { collection, id: tr.id },
})
}
onCreate={onTranslate}
/>
</div>
)}

Expand Down
97 changes: 90 additions & 7 deletions packages/admin/src/components/MenuEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
File as FileIcon,
} from "@phosphor-icons/react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useNavigate } from "@tanstack/react-router";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import * as React from "react";

import {
Expand All @@ -25,15 +25,22 @@ import {
deleteMenuItem,
updateMenuItem,
reorderMenuItems,
fetchMenuTranslations,
createMenuTranslation,
type MenuItem,
} from "../lib/api";
import { fetchManifest } from "../lib/api/client.js";
import { ArrowPrev } from "./ArrowIcons.js";
import { ContentPickerModal } from "./ContentPickerModal";
import { DialogError, getMutationError } from "./DialogError.js";
import { useI18nConfig } from "./LocaleSwitcher.js";
import { TranslationsPanel } from "./TranslationsPanel.js";

export function MenuEditor() {
const { t } = useLingui();
const { name } = useParams({ from: "/_admin/menus/$name" });
const search = useSearch({ from: "/_admin/menus/$name" });
const routeLocale = search.locale;
const navigate = useNavigate();
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();
Expand All @@ -44,12 +51,60 @@ export function MenuEditor() {
const [addError, setAddError] = React.useState<string | null>(null);
const [editError, setEditError] = React.useState<string | null>(null);

const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
});
const i18n = useI18nConfig(manifest);

const { data: menu, isLoading } = useQuery({
queryKey: ["menu", name],
queryFn: () => fetchMenu(name),
queryKey: ["menu", name, routeLocale ?? null],
queryFn: () => fetchMenu(name, { locale: routeLocale }),
staleTime: Infinity,
});

// The locale we lock mutations to: explicit URL param wins; else fall back
// to whatever the loaded menu row says (handles entry from the old /menus/$name
// URL without a locale query).
const menuLocale = routeLocale ?? menu?.locale;

const { data: translationsData } = useQuery({
queryKey: ["menu-translations", name, menuLocale ?? null],
queryFn: () => fetchMenuTranslations(name, { locale: menuLocale }),
enabled: !!menu && !!i18n && i18n.locales.length > 1,
});

const translateMutation = useMutation({
mutationFn: (targetLocale: string) =>
createMenuTranslation(
name,
{ locale: targetLocale, label: menu?.label },
{ locale: menuLocale },
),
onSuccess: (translated) => {
void queryClient.invalidateQueries({ queryKey: ["menus"] });
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
void queryClient.invalidateQueries({ queryKey: ["menu-translations", name] });
toastManager.add({
title: t`Translation created`,
description: t`Menu "${translated.label}" (${translated.locale.toUpperCase()}) created.`,
});
// Switch the editor to the new locale so the user keeps editing.
void navigate({
to: "/menus/$name",
params: { name },
search: { locale: translated.locale },
});
},
onError: (error: Error) => {
toastManager.add({
title: t`Error`,
description: error.message,
type: "error",
});
},
});

// Sync local items with fetched data
React.useEffect(() => {
if (menu?.items) {
Expand All @@ -58,7 +113,8 @@ export function MenuEditor() {
}, [menu]);

const createMutation = useMutation({
mutationFn: (input: Parameters<typeof createMenuItem>[1]) => createMenuItem(name, input),
mutationFn: (input: Parameters<typeof createMenuItem>[1]) =>
createMenuItem(name, input, { locale: menuLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
setIsAddOpen(false);
Expand All @@ -70,7 +126,7 @@ export function MenuEditor() {
});

const deleteMutation = useMutation({
mutationFn: (itemId: string) => deleteMenuItem(name, itemId),
mutationFn: (itemId: string) => deleteMenuItem(name, itemId, { locale: menuLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
toastManager.add({
Expand All @@ -94,7 +150,7 @@ export function MenuEditor() {
}: {
itemId: string;
input: Parameters<typeof updateMenuItem>[2];
}) => updateMenuItem(name, itemId, input),
}) => updateMenuItem(name, itemId, input, { locale: menuLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
setEditingItem(null);
Expand All @@ -109,7 +165,8 @@ export function MenuEditor() {
});

const reorderMutation = useMutation({
mutationFn: (input: Parameters<typeof reorderMenuItems>[1]) => reorderMenuItems(name, input),
mutationFn: (input: Parameters<typeof reorderMenuItems>[1]) =>
reorderMenuItems(name, input, { locale: menuLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["menu", name] });
toastManager.add({
Expand Down Expand Up @@ -309,6 +366,32 @@ export function MenuEditor() {
onSelect={handleAddContent}
/>

{i18n && i18n.locales.length > 1 && menu ? (
<div className="border rounded-lg p-4">
<TranslationsPanel
locales={i18n.locales}
defaultLocale={i18n.defaultLocale}
currentLocale={menu.locale}
translations={
translationsData?.translations.map((tr) => ({ id: tr.id, locale: tr.locale })) ?? [
{ id: menu.id, locale: menu.locale },
]
}
onOpen={(tr) =>
navigate({
to: "/menus/$name",
params: { name },
search: { locale: tr.locale },
})
}
onCreate={(target) => translateMutation.mutate(target)}
pendingLocale={
translateMutation.isPending ? (translateMutation.variables ?? null) : null
}
/>
</div>
) : null}

{localItems.length === 0 ? (
<div className="border rounded-lg p-12 text-center">
<LinkIcon className="mx-auto h-12 w-12 text-kumo-subtle mb-4" />
Expand Down
Loading
Loading