-
{menu.label}
+
+ {menu.label}
+ {i18n ? (
+
+ {menu.locale}
+
+ ) : null}
+
{menu.name} • {menu.itemCount || 0} items
@@ -179,6 +217,7 @@ export function MenuList() {
diff --git a/packages/admin/src/components/TaxonomyManager.tsx b/packages/admin/src/components/TaxonomyManager.tsx
index 70ef699a1..40c138534 100644
--- a/packages/admin/src/components/TaxonomyManager.tsx
+++ b/packages/admin/src/components/TaxonomyManager.tsx
@@ -15,15 +15,19 @@ import { fetchManifest } from "../lib/api/client.js";
import type { TaxonomyTerm, TaxonomyDef, CreateTaxonomyInput } from "../lib/api/taxonomies.js";
import {
fetchTaxonomyDef,
+ fetchTermTranslations,
fetchTerms,
createTaxonomy,
createTerm,
+ createTermTranslation,
updateTerm,
deleteTerm,
} from "../lib/api/taxonomies.js";
import { slugify } from "../lib/utils";
import { ConfirmDialog } from "./ConfirmDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
+import { LocaleSwitcher, useI18nConfig } from "./LocaleSwitcher.js";
+import { TranslationsPanel } from "./TranslationsPanel.js";
interface TaxonomyManagerProps {
taxonomyName: string;
@@ -49,11 +53,15 @@ function TermRow({
level = 0,
onEdit,
onDelete,
+ onTranslate,
+ canTranslate,
}: {
term: TaxonomyTerm;
level?: number;
onEdit: (term: TaxonomyTerm) => void;
onDelete: (term: TaxonomyTerm) => void;
+ onTranslate?: (term: TaxonomyTerm) => void;
+ canTranslate: boolean;
}) {
const { t } = useLingui();
return (
@@ -65,6 +73,16 @@ function TermRow({
+ {canTranslate && onTranslate ? (
+
onTranslate(term)}
+ >
+ {t`Translate`}
+
+ ) : null}
))}
>
);
}
+/**
+ * Dialog to pick a target locale for creating a term translation.
+ */
+function TranslateTermDialog({
+ term,
+ taxonomyName,
+ locales,
+ activeLocale,
+ isPending,
+ error,
+ onClose,
+ onSubmit,
+}: {
+ term: TaxonomyTerm;
+ taxonomyName: string;
+ locales: string[];
+ activeLocale: string | undefined;
+ isPending: boolean;
+ error: Error | null;
+ onClose: () => void;
+ onSubmit: (locale: string) => void;
+}) {
+ const { t } = useLingui();
+ const otherLocales = locales.filter((l) => l !== activeLocale);
+ const [selected, setSelected] = React.useState
(otherLocales[0] ?? "");
+
+ return (
+ {
+ if (!isOpen) onClose();
+ }}
+ >
+
+
+
+
+ {t`Translate "${term.label}"`}
+
+
+ {t`Taxonomy: ${taxonomyName}`}
+
+
+
(
+
+
+
+ )}
+ />
+
+
+ setSelected(v ?? "")}
+ items={Object.fromEntries(otherLocales.map((l) => [l, l.toUpperCase()]))}
+ >
+ {otherLocales.map((l) => (
+
+ {l.toUpperCase()}
+
+ ))}
+
+
+
+
+
+ {t`Cancel`}
+
+ onSubmit(selected)}
+ >
+ {isPending ? t`Translating...` : t`Translate`}
+
+
+
+
+ );
+}
+
/**
* Term form dialog
*/
@@ -106,6 +209,9 @@ function TermFormDialog({
taxonomyDef,
term,
allTerms,
+ locale,
+ i18n,
+ onOpenTranslation,
}: {
open: boolean;
onClose: () => void;
@@ -113,6 +219,9 @@ function TermFormDialog({
taxonomyDef: TaxonomyDef;
term?: TaxonomyTerm;
allTerms: TaxonomyTerm[];
+ locale?: string;
+ i18n: { defaultLocale: string; locales: string[] } | null;
+ onOpenTranslation?: (translatedTerm: { slug: string; locale: string }) => void;
}) {
const { t } = useLingui();
const queryClient = useQueryClient();
@@ -147,6 +256,7 @@ function TermFormDialog({
label,
parentId: parentId || undefined,
description: description || undefined,
+ locale,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
@@ -162,12 +272,17 @@ function TermFormDialog({
const updateMutation = useMutation({
mutationFn: () => {
if (!term) throw new Error("No term to update");
- return updateTerm(taxonomyName, term.slug, {
- slug,
- label,
- parentId: parentId || undefined,
- description: description || undefined,
- });
+ return updateTerm(
+ taxonomyName,
+ term.slug,
+ {
+ slug,
+ label,
+ parentId: parentId || undefined,
+ description: description || undefined,
+ },
+ { locale: term.locale ?? locale },
+ );
},
onSuccess: () => {
void queryClient.invalidateQueries({
@@ -180,6 +295,37 @@ function TermFormDialog({
},
});
+ // Translations list (only when editing an existing term and i18n is on).
+ const { data: translationsData } = useQuery({
+ queryKey: ["term-translations", taxonomyName, term?.id ?? null],
+ queryFn: () => {
+ if (!term) throw new Error("No term");
+ return fetchTermTranslations(taxonomyName, term.slug, { locale: term.locale ?? locale });
+ },
+ enabled: !!term && !!i18n && i18n.locales.length > 1,
+ });
+
+ const translateMutation = useMutation({
+ mutationFn: (targetLocale: string) => {
+ if (!term) throw new Error("No term");
+ return createTermTranslation(
+ taxonomyName,
+ term.slug,
+ { locale: targetLocale, label: term.label, slug: term.slug },
+ { locale: term.locale ?? locale },
+ );
+ },
+ onSuccess: (translated) => {
+ void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName] });
+ void queryClient.invalidateQueries({ queryKey: ["term-translations", taxonomyName] });
+ onClose();
+ onOpenTranslation?.({ slug: translated.slug, locale: translated.locale });
+ },
+ onError: (err: Error) => {
+ setError(err.message);
+ },
+ });
+
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -294,9 +440,29 @@ function TermFormDialog({
message={
error ||
getMutationError(createMutation.error) ||
- getMutationError(updateMutation.error)
+ getMutationError(updateMutation.error) ||
+ getMutationError(translateMutation.error)
}
/>
+
+ {term && i18n && i18n.locales.length > 1 ? (
+
+ {
+ onClose();
+ onOpenTranslation?.({ slug: term.slug, locale: tr.locale });
+ }}
+ onCreate={(target) => translateMutation.mutate(target)}
+ pendingLocale={
+ translateMutation.isPending ? (translateMutation.variables ?? null) : null
+ }
+ />
+
+ ) : null}
@@ -543,28 +709,61 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
const [editingTerm, setEditingTerm] = React.useState
();
const [deleteTarget, setDeleteTarget] = React.useState(null);
const [createTaxonomyOpen, setCreateTaxonomyOpen] = React.useState(false);
+ const [translateTarget, setTranslateTarget] = React.useState(null);
+ const { data: manifest } = useQuery({
+ queryKey: ["manifest"],
+ queryFn: fetchManifest,
+ });
+ const i18n = useI18nConfig(manifest);
+ const [activeLocale, setActiveLocale] = React.useState(undefined);
+ React.useEffect(() => {
+ if (i18n && !activeLocale) setActiveLocale(i18n.defaultLocale);
+ }, [i18n, activeLocale]);
+
+ // The taxonomy definition is looked up without filtering by locale — the
+ // def is primarily structural ("does this taxonomy exist, is it
+ // hierarchical, which collections use it"). Label translations exist per
+ // locale but are not required for the page to render.
const { data: taxonomyDef, isLoading: defLoading } = useQuery({
queryKey: ["taxonomy-def", taxonomyName],
queryFn: () => fetchTaxonomyDef(taxonomyName),
});
const { data: terms = [], isLoading: termsLoading } = useQuery({
- queryKey: ["taxonomy-terms", taxonomyName],
- queryFn: () => fetchTerms(taxonomyName),
+ queryKey: ["taxonomy-terms", taxonomyName, activeLocale],
+ queryFn: () => fetchTerms(taxonomyName, { locale: activeLocale }),
});
const deleteMutation = useMutation({
- mutationFn: (term: TaxonomyTerm) => deleteTerm(taxonomyName, term.slug),
+ mutationFn: (term: TaxonomyTerm) =>
+ deleteTerm(taxonomyName, term.slug, { locale: activeLocale }),
onSuccess: () => {
- void queryClient.invalidateQueries({
- queryKey: ["taxonomy-terms", taxonomyName],
- });
+ void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName] });
setDeleteTarget(null);
toastManager.add({ title: t`Term deleted` });
},
});
+ const translateMutation = useMutation({
+ mutationFn: ({ term, locale }: { term: TaxonomyTerm; locale: string }) =>
+ createTermTranslation(
+ taxonomyName,
+ term.slug,
+ { locale, label: term.label, slug: term.slug },
+ { locale: activeLocale },
+ ),
+ onSuccess: (term) => {
+ void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName] });
+ setTranslateTarget(null);
+ setActiveLocale(term.locale);
+ toastManager.add({
+ title: t`Translation created`,
+ description: t`Term "${term.label}" created in ${term.locale.toUpperCase()}.`,
+ });
+ },
+ });
+
const handleEdit = (term: TaxonomyTerm) => {
setEditingTerm(term);
setFormOpen(true);
@@ -595,14 +794,22 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
return (
-
+
{taxonomyDef.label}
{t`Manage ${taxonomyDef.label.toLowerCase()} for ${taxonomyDef.collections.join(", ")}`}
-
+
+ {i18n && activeLocale ? (
+
+ ) : null}
} onClick={() => setCreateTaxonomyOpen(true)}>
{t`New Taxonomy`}
@@ -628,7 +835,14 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
) : (
{terms.map((term) => (
-
+ 1}
+ />
))}
)}
@@ -641,8 +855,27 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
taxonomyDef={taxonomyDef}
term={editingTerm}
allTerms={flatTerms}
+ locale={activeLocale}
+ i18n={i18n}
+ onOpenTranslation={(tr) => setActiveLocale(tr.locale)}
/>
+ {i18n && translateTarget ? (
+
{
+ setTranslateTarget(null);
+ translateMutation.reset();
+ }}
+ onSubmit={(locale) => translateMutation.mutate({ term: translateTarget, locale })}
+ />
+ ) : null}
+
{
diff --git a/packages/admin/src/components/TranslationsPanel.tsx b/packages/admin/src/components/TranslationsPanel.tsx
new file mode 100644
index 000000000..c9e02b22d
--- /dev/null
+++ b/packages/admin/src/components/TranslationsPanel.tsx
@@ -0,0 +1,108 @@
+/**
+ * Shared "Translations" sidebar panel. Matches the look of the translations
+ * section in ContentEditor — reused across content edit, menu edit and
+ * taxonomy term edit so the admin shows one consistent affordance.
+ */
+
+import { Button } from "@cloudflare/kumo";
+import { useLingui } from "@lingui/react/macro";
+import * as React from "react";
+
+import { cn } from "../lib/utils.js";
+
+interface PanelTranslation {
+ id: string;
+ locale: string;
+}
+
+export interface TranslationsPanelProps {
+ /** Heading shown above the list (default: "Translations"). */
+ title?: string;
+ /** All configured locales. */
+ locales: string[];
+ /** Marked as "(default)" in the list. */
+ defaultLocale: string;
+ /** Locale of the row being edited — rendered with the "current" highlight. */
+ currentLocale: string | undefined;
+ /** Locale variants that already exist (may include the current locale).
+ * The panel only needs `id` + `locale`; callers may pass richer summaries. */
+ translations: PanelTranslation[];
+ /** Called when the user clicks "Edit" on a sibling translation. */
+ onOpen?: (summary: PanelTranslation) => void;
+ /** Called when the user clicks "Translate" for a missing locale. */
+ onCreate?: (locale: string) => void;
+ /** Locale currently being created; used to disable its button. */
+ pendingLocale?: string | null;
+}
+
+export function TranslationsPanel({
+ title,
+ locales,
+ defaultLocale,
+ currentLocale,
+ translations,
+ onOpen,
+ onCreate,
+ pendingLocale,
+}: TranslationsPanelProps) {
+ const { t } = useLingui();
+ const byLocale = React.useMemo(
+ () => new Map(translations.map((tr) => [tr.locale, tr])),
+ [translations],
+ );
+
+ return (
+
+
{title ?? t`Translations`}
+
+ {locales.map((locale) => {
+ const translation = byLocale.get(locale);
+ const isCurrent = locale === currentLocale;
+ return (
+
+
+ {locale}
+ {locale === defaultLocale && (
+ {t` (default)`}
+ )}
+ {isCurrent && {t`current`} }
+
+ {isCurrent ? null : translation && onOpen ? (
+
onOpen(translation)}
+ >
+ {t`Edit`}
+
+ ) : !translation && onCreate ? (
+
onCreate(locale)}
+ >
+ {pendingLocale === locale ? t`Translating...` : t`Translate`}
+
+ ) : null}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/admin/src/components/Widgets.tsx b/packages/admin/src/components/Widgets.tsx
index e1e3d226e..f90d507e2 100644
--- a/packages/admin/src/components/Widgets.tsx
+++ b/packages/admin/src/components/Widgets.tsx
@@ -783,7 +783,7 @@ function WidgetEditor({
const { data: menus = [] } = useQuery({
queryKey: ["menus"],
- queryFn: fetchMenus,
+ queryFn: () => fetchMenus(),
enabled: widget.type === "menu",
});
diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts
index ae3a98e05..5ccba56ef 100644
--- a/packages/admin/src/lib/api/index.ts
+++ b/packages/admin/src/lib/api/index.ts
@@ -154,11 +154,14 @@ export {
type Menu,
type MenuItem,
type MenuWithItems,
+ type MenuTranslation,
+ type MenuTranslationsResponse,
type CreateMenuInput,
type UpdateMenuInput,
type CreateMenuItemInput,
type UpdateMenuItemInput,
type ReorderMenuItemsInput,
+ type LocaleOptions as MenuLocaleOptions,
fetchMenus,
fetchMenu,
createMenu,
@@ -168,6 +171,8 @@ export {
updateMenuItem,
deleteMenuItem,
reorderMenuItems,
+ fetchMenuTranslations,
+ createMenuTranslation,
} from "./menus.js";
// Widget areas
@@ -208,6 +213,8 @@ export {
export {
type TaxonomyTerm,
type TaxonomyDef,
+ type TermTranslation,
+ type TermTranslationsResponse,
type CreateTaxonomyInput,
type CreateTermInput,
type UpdateTermInput,
@@ -218,6 +225,8 @@ export {
createTerm,
updateTerm,
deleteTerm,
+ fetchTermTranslations,
+ createTermTranslation,
} from "./taxonomies.js";
// WordPress import
diff --git a/packages/admin/src/lib/api/menus.ts b/packages/admin/src/lib/api/menus.ts
index 7f37f097a..e6417b4b8 100644
--- a/packages/admin/src/lib/api/menus.ts
+++ b/packages/admin/src/lib/api/menus.ts
@@ -1,5 +1,9 @@
/**
- * Menu management APIs
+ * Menu management APIs.
+ *
+ * i18n: all endpoints accept an optional `locale`. When omitted, the server
+ * returns or acts on all locales (legacy behaviour for clients that haven't
+ * been updated yet).
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
@@ -11,6 +15,8 @@ export interface Menu {
created_at: string;
updated_at: string;
itemCount?: number;
+ locale: string;
+ translation_group: string | null;
}
export interface MenuItem {
@@ -27,15 +33,32 @@ export interface MenuItem {
target: string | null;
css_classes: string | null;
created_at: string;
+ locale: string;
+ translation_group: string | null;
}
export interface MenuWithItems extends Menu {
items: MenuItem[];
}
+export interface MenuTranslation {
+ id: string;
+ name: string;
+ label: string;
+ locale: string;
+ updatedAt: string;
+}
+
+export interface MenuTranslationsResponse {
+ translationGroup: string | null;
+ translations: MenuTranslation[];
+}
+
export interface CreateMenuInput {
name: string;
label: string;
+ locale?: string;
+ translationOf?: string;
}
export interface UpdateMenuInput {
@@ -73,19 +96,29 @@ export interface ReorderMenuItemsInput {
}>;
}
+export interface LocaleOptions {
+ locale?: string;
+}
+
+function withLocale(path: string, locale?: string): string {
+ return locale
+ ? `${path}${path.includes("?") ? "&" : "?"}locale=${encodeURIComponent(locale)}`
+ : path;
+}
+
/**
* Fetch all menus
*/
-export async function fetchMenus(): Promise {
- const response = await apiFetch(`${API_BASE}/menus`);
+export async function fetchMenus(options: LocaleOptions = {}): Promise {
+ const response = await apiFetch(withLocale(`${API_BASE}/menus`, options.locale));
return parseApiResponse(response, "Failed to fetch menus");
}
/**
* Fetch a single menu with items
*/
-export async function fetchMenu(name: string): Promise {
- const response = await apiFetch(`${API_BASE}/menus/${name}`);
+export async function fetchMenu(name: string, options: LocaleOptions = {}): Promise {
+ const response = await apiFetch(withLocale(`${API_BASE}/menus/${name}`, options.locale));
return parseApiResponse(response, "Failed to fetch menu");
}
@@ -104,8 +137,12 @@ export async function createMenu(input: CreateMenuInput): Promise {
/**
* Update a menu
*/
-export async function updateMenu(name: string, input: UpdateMenuInput): Promise {
- const response = await apiFetch(`${API_BASE}/menus/${name}`, {
+export async function updateMenu(
+ name: string,
+ input: UpdateMenuInput,
+ options: LocaleOptions = {},
+): Promise {
+ const response = await apiFetch(withLocale(`${API_BASE}/menus/${name}`, options.locale), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
@@ -116,8 +153,8 @@ export async function updateMenu(name: string, input: UpdateMenuInput): Promise<
/**
* Delete a menu
*/
-export async function deleteMenu(name: string): Promise {
- const response = await apiFetch(`${API_BASE}/menus/${name}`, {
+export async function deleteMenu(name: string, options: LocaleOptions = {}): Promise {
+ const response = await apiFetch(withLocale(`${API_BASE}/menus/${name}`, options.locale), {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete menu");
@@ -129,12 +166,16 @@ export async function deleteMenu(name: string): Promise {
export async function createMenuItem(
menuName: string,
input: CreateMenuItemInput,
+ options: LocaleOptions = {},
): Promise {
- const response = await apiFetch(`${API_BASE}/menus/${menuName}/items`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/menus/${menuName}/items`, options.locale),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ },
+ );
return parseApiResponse(response, "Failed to create menu item");
}
@@ -145,22 +186,31 @@ export async function updateMenuItem(
menuName: string,
itemId: string,
input: UpdateMenuItemInput,
+ options: LocaleOptions = {},
): Promise {
- const response = await apiFetch(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, options.locale),
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ },
+ );
return parseApiResponse(response, "Failed to update menu item");
}
/**
* Delete a menu item
*/
-export async function deleteMenuItem(menuName: string, itemId: string): Promise {
- const response = await apiFetch(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, {
- method: "DELETE",
- });
+export async function deleteMenuItem(
+ menuName: string,
+ itemId: string,
+ options: LocaleOptions = {},
+): Promise {
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, options.locale),
+ { method: "DELETE" },
+ );
if (!response.ok) await throwResponseError(response, "Failed to delete menu item");
}
@@ -170,11 +220,46 @@ export async function deleteMenuItem(menuName: string, itemId: string): Promise<
export async function reorderMenuItems(
menuName: string,
input: ReorderMenuItemsInput,
+ options: LocaleOptions = {},
): Promise {
- const response = await apiFetch(`${API_BASE}/menus/${menuName}/reorder`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/menus/${menuName}/reorder`, options.locale),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ },
+ );
return parseApiResponse(response, "Failed to reorder menu items");
}
+
+/** List every translation (locale variant) of a menu. */
+export async function fetchMenuTranslations(
+ name: string,
+ options: LocaleOptions = {},
+): Promise {
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/menus/${name}/translations`, options.locale),
+ );
+ return parseApiResponse(response, "Failed to fetch menu translations");
+}
+
+/**
+ * Create a new locale translation of a menu. The new menu inherits the
+ * source's items and label unless overridden.
+ */
+export async function createMenuTranslation(
+ name: string,
+ input: { locale: string; label?: string },
+ options: LocaleOptions = {},
+): Promise {
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/menus/${name}/translations`, options.locale),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ },
+ );
+ return parseApiResponse(response, "Failed to create menu translation");
+}
diff --git a/packages/admin/src/lib/api/taxonomies.ts b/packages/admin/src/lib/api/taxonomies.ts
index a6b5ed86a..b2bc421f5 100644
--- a/packages/admin/src/lib/api/taxonomies.ts
+++ b/packages/admin/src/lib/api/taxonomies.ts
@@ -1,5 +1,10 @@
/**
- * Taxonomies API (categories, tags, custom taxonomies)
+ * Taxonomies API (categories, tags, custom taxonomies).
+ *
+ * All endpoints are locale-aware. When no `locale` option is passed we omit
+ * the query param and the server falls back to its usual resolution (no
+ * filter, returning every locale — same as pre-i18n behaviour for clients
+ * that haven't yet been updated).
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
@@ -13,6 +18,8 @@ export interface TaxonomyTerm {
description?: string;
children: TaxonomyTerm[];
count?: number;
+ locale: string;
+ translationGroup: string | null;
}
export interface TaxonomyDef {
@@ -22,13 +29,42 @@ export interface TaxonomyDef {
labelSingular?: string;
hierarchical: boolean;
collections: string[];
+ locale: string;
+ translationGroup: string | null;
+}
+
+export interface TermTranslation {
+ id: string;
+ slug: string;
+ label: string;
+ locale: string;
+}
+
+export interface TermTranslationsResponse {
+ translationGroup: string | null;
+ translations: TermTranslation[];
+}
+
+export interface TaxonomyDefTranslation {
+ id: string;
+ name: string;
+ label: string;
+ locale: string;
+}
+
+export interface TaxonomyDefTranslationsResponse {
+ translationGroup: string | null;
+ translations: TaxonomyDefTranslation[];
}
export interface CreateTaxonomyInput {
name: string;
label: string;
+ labelSingular?: string;
hierarchical?: boolean;
collections?: string[];
+ locale?: string;
+ translationOf?: string;
}
export interface CreateTermInput {
@@ -36,6 +72,8 @@ export interface CreateTermInput {
label: string;
parentId?: string;
description?: string;
+ locale?: string;
+ translationOf?: string;
}
export interface UpdateTermInput {
@@ -45,11 +83,21 @@ export interface UpdateTermInput {
description?: string;
}
+export interface LocaleOptions {
+ locale?: string;
+}
+
+function withLocale(path: string, locale?: string): string {
+ return locale
+ ? `${path}${path.includes("?") ? "&" : "?"}locale=${encodeURIComponent(locale)}`
+ : path;
+}
+
/**
* Fetch all taxonomy definitions
*/
-export async function fetchTaxonomyDefs(): Promise {
- const response = await apiFetch(`${API_BASE}/taxonomies`);
+export async function fetchTaxonomyDefs(options: LocaleOptions = {}): Promise {
+ const response = await apiFetch(withLocale(`${API_BASE}/taxonomies`, options.locale));
const data = await parseApiResponse<{ taxonomies: TaxonomyDef[] }>(
response,
"Failed to fetch taxonomies",
@@ -60,8 +108,11 @@ export async function fetchTaxonomyDefs(): Promise {
/**
* Fetch taxonomy definition by name
*/
-export async function fetchTaxonomyDef(name: string): Promise {
- const defs = await fetchTaxonomyDefs();
+export async function fetchTaxonomyDef(
+ name: string,
+ options: LocaleOptions = {},
+): Promise {
+ const defs = await fetchTaxonomyDefs(options);
return defs.find((t) => t.name === name) || null;
}
@@ -84,8 +135,13 @@ export async function createTaxonomy(input: CreateTaxonomyInput): Promise {
- const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms`);
+export async function fetchTerms(
+ taxonomyName: string,
+ options: LocaleOptions = {},
+): Promise {
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/taxonomies/${taxonomyName}/terms`, options.locale),
+ );
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(response, "Failed to fetch terms");
return data.terms;
}
@@ -113,12 +169,16 @@ export async function updateTerm(
taxonomyName: string,
slug: string,
input: UpdateTermInput,
+ options: LocaleOptions = {},
): Promise {
- const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, options.locale),
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ },
+ );
const data = await parseApiResponse<{ term: TaxonomyTerm }>(response, "Failed to update term");
return data.term;
}
@@ -126,9 +186,51 @@ export async function updateTerm(
/**
* Delete a term
*/
-export async function deleteTerm(taxonomyName: string, slug: string): Promise {
- const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, {
- method: "DELETE",
- });
+export async function deleteTerm(
+ taxonomyName: string,
+ slug: string,
+ options: LocaleOptions = {},
+): Promise {
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, options.locale),
+ { method: "DELETE" },
+ );
if (!response.ok) await throwResponseError(response, "Failed to delete term");
}
+
+/** List every translation (locale variant) of a term. */
+export async function fetchTermTranslations(
+ taxonomyName: string,
+ slug: string,
+ options: LocaleOptions = {},
+): Promise {
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}/translations`, options.locale),
+ );
+ return parseApiResponse(response, "Failed to fetch term translations");
+}
+
+/**
+ * Create a new locale translation of a term. The new term inherits slug,
+ * label, parent, and description from the source unless overridden in `input`.
+ */
+export async function createTermTranslation(
+ taxonomyName: string,
+ slug: string,
+ input: { locale: string; label?: string; slug?: string },
+ options: LocaleOptions = {},
+): Promise {
+ const response = await apiFetch(
+ withLocale(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}/translations`, options.locale),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ },
+ );
+ const data = await parseApiResponse<{ term: TaxonomyTerm }>(
+ response,
+ "Failed to create term translation",
+ );
+ return data.term;
+}
diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx
index ca6bb8512..1a72ca529 100644
--- a/packages/admin/src/router.tsx
+++ b/packages/admin/src/router.tsx
@@ -1326,6 +1326,11 @@ const menuEditorRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/menus/$name",
component: MenuEditor,
+ validateSearch: (search: Record) => {
+ return {
+ locale: typeof search.locale === "string" ? search.locale : undefined,
+ };
+ },
});
// Taxonomy manager route
diff --git a/packages/admin/tests/components/MenuEditor.test.tsx b/packages/admin/tests/components/MenuEditor.test.tsx
index 78e72960c..0d411cc69 100644
--- a/packages/admin/tests/components/MenuEditor.test.tsx
+++ b/packages/admin/tests/components/MenuEditor.test.tsx
@@ -24,6 +24,7 @@ vi.mock("@tanstack/react-router", async () => {
),
useParams: () => ({ name: "main-menu" }),
+ useSearch: () => ({}),
useNavigate: () => vi.fn(),
};
});
diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts
index 50f5dfa25..71fe4504b 100644
--- a/packages/core/src/api/handlers/content.ts
+++ b/packages/core/src/api/handlers/content.ts
@@ -476,6 +476,17 @@ export async function handleContentCreate(
}
await hydrateBylines(trx, collection, created);
+ // When this row is a translation of an existing item, inherit the
+ // source's taxonomy assignments. The pivot stores translation_groups
+ // so the copied rows apply to every locale of the translation group
+ // (existing per-locale assignments still resolve correctly in
+ // `getEntryTerms` because the join picks the locale-specific row).
+ if (body.translationOf) {
+ const { TaxonomyRepository } = await import("../../database/repositories/taxonomy.js");
+ const taxRepo = new TaxonomyRepository(trx);
+ await taxRepo.copyEntryTerms(collection, body.translationOf, created.id);
+ }
+
// Side-write SEO data if provided
if (body.seo && hasSeo) {
const seoRepo = new SeoRepository(trx);
diff --git a/packages/core/src/api/handlers/menus.ts b/packages/core/src/api/handlers/menus.ts
index 578de7a0a..7f062963b 100644
--- a/packages/core/src/api/handlers/menus.ts
+++ b/packages/core/src/api/handlers/menus.ts
@@ -1,29 +1,30 @@
/**
- * Menu CRUD handlers
+ * Menu CRUD handlers.
*
- * Business logic for menu and menu-item endpoints.
- * Routes are thin wrappers that parse input, check auth, and call these.
+ * Business logic for menu and menu-item endpoints. Routes are thin wrappers
+ * that parse input, check auth, and call these.
+ *
+ * i18n: Menus are per-locale. `(name, locale)` is unique, so the same `name`
+ * (e.g. "primary") can exist in several locales within one translation_group.
+ * Menu items carry a `locale` + `translation_group` as well, and their
+ * `reference_id` points at the referenced content's translation_group (not a
+ * specific row id), so a single menu item target survives content translations.
*/
-import type { Kysely } from "kysely";
+import type { Kysely, Selectable } from "kysely";
import { ulid } from "ulidx";
import { withTransaction } from "../../database/transaction.js";
import type { Database, MenuItemTable, MenuTable } from "../../database/types.js";
+import { getI18nConfig } from "../../i18n/config.js";
import type { ApiResult } from "../types.js";
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
-type MenuRow = Omit & {
- created_at: string;
- updated_at: string;
-};
-
-type MenuItemRow = Omit & {
- created_at: string;
-};
+export type MenuRow = Selectable;
+export type MenuItemRow = Selectable;
export interface MenuListItem extends MenuRow {
itemCount: number;
@@ -33,18 +34,34 @@ export interface MenuWithItems extends MenuRow {
items: MenuItemRow[];
}
+export interface MenuTranslationsResponse {
+ translationGroup: string | null;
+ translations: Array<{
+ id: string;
+ name: string;
+ locale: string;
+ label: string;
+ updatedAt: string;
+ }>;
+}
+
// ---------------------------------------------------------------------------
// Menu handlers
// ---------------------------------------------------------------------------
/**
- * List all menus with item counts.
+ * List menus with item counts. Filter by `locale` when provided; otherwise
+ * return every menu row (each locale counts as its own menu for admin listing
+ * purposes).
*/
-export async function handleMenuList(db: Kysely): Promise> {
+export async function handleMenuList(
+ db: Kysely,
+ options: { locale?: string } = {},
+): Promise> {
try {
// Single query: LEFT JOIN + GROUP BY for the per-menu item count.
// Avoids the N+1 of one count query per menu.
- const rows = await db
+ let query = db
.selectFrom("_emdash_menus as m")
.leftJoin("_emdash_menu_items as i", "i.menu_id", "m.id")
.select(({ fn }) => [
@@ -53,11 +70,22 @@ export async function handleMenuList(db: Kysely): Promise("i.id").as("itemCount"),
])
- .groupBy(["m.id", "m.name", "m.label", "m.created_at", "m.updated_at"])
- .orderBy("m.name", "asc")
- .execute();
+ .groupBy([
+ "m.id",
+ "m.name",
+ "m.label",
+ "m.created_at",
+ "m.updated_at",
+ "m.locale",
+ "m.translation_group",
+ ])
+ .orderBy("m.name", "asc");
+ if (options.locale !== undefined) query = query.where("m.locale", "=", options.locale);
+ const rows = await query.execute();
// SQLite returns count as `number`, but some dialects (Postgres)
// return `string` from a count() aggregate. Normalize to number.
@@ -67,6 +95,8 @@ export async function handleMenuList(db: Kysely): Promise): Promise,
- input: { name: string; label: string },
+ input: { name: string; label: string; locale?: string; translationOf?: string },
): Promise> {
try {
+ // Resolve translation group + source (if we're creating a translation).
+ let translationGroup: string | null = null;
+ let sourceMenu: MenuRow | null = null;
+ if (input.translationOf) {
+ const src = await db
+ .selectFrom("_emdash_menus")
+ .selectAll()
+ .where("id", "=", input.translationOf)
+ .executeTakeFirst();
+ if (!src) {
+ return {
+ success: false,
+ error: { code: "NOT_FOUND", message: "Source menu for translation not found" },
+ };
+ }
+ sourceMenu = src;
+ translationGroup = src.translation_group ?? src.id;
+ }
+
+ // Duplicate guard: same (name, locale). Falls back to the configured
+ // defaultLocale to match the column DEFAULT set by migration 036.
+ const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en";
const existing = await db
.selectFrom("_emdash_menus")
.select("id")
.where("name", "=", input.name)
+ .where("locale", "=", effectiveLocale)
.executeTakeFirst();
-
if (existing) {
return {
success: false,
- error: { code: "CONFLICT", message: `Menu with name "${input.name}" already exists` },
+ error: {
+ code: "CONFLICT",
+ message: `Menu "${input.name}" already exists${
+ input.locale ? ` in locale "${input.locale}"` : ""
+ }`,
+ },
};
}
const id = ulid();
- await db
- .insertInto("_emdash_menus")
- .values({
- id,
- name: input.name,
- label: input.label,
- })
- .execute();
+
+ await withTransaction(db, async (trx) => {
+ await trx
+ .insertInto("_emdash_menus")
+ .values({
+ id,
+ name: input.name,
+ label: input.label,
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
+ translation_group: translationGroup ?? id,
+ })
+ .execute();
+
+ // Clone items from the source menu (same reference_ids — they are
+ // translation_groups, which are locale-agnostic). Each clone
+ // inherits the source item's translation_group so a nav entry
+ // identifies as the same logical item across menu translations.
+ if (sourceMenu) {
+ const sourceItems = await trx
+ .selectFrom("_emdash_menu_items")
+ .selectAll()
+ .where("menu_id", "=", sourceMenu.id)
+ .orderBy("sort_order", "asc")
+ .execute();
+ if (sourceItems.length > 0) {
+ // Build old-id → new-id map so parent pointers land on the clones.
+ const idMap = new Map();
+ for (const item of sourceItems) idMap.set(item.id, ulid());
+
+ await trx
+ .insertInto("_emdash_menu_items")
+ .values(
+ sourceItems.map((item) => {
+ const newId = idMap.get(item.id)!;
+ return {
+ id: newId,
+ menu_id: id,
+ parent_id: item.parent_id ? (idMap.get(item.parent_id) ?? null) : null,
+ sort_order: item.sort_order,
+ type: item.type,
+ reference_collection: item.reference_collection,
+ reference_id: item.reference_id,
+ custom_url: item.custom_url,
+ label: item.label,
+ title_attr: item.title_attr,
+ target: item.target,
+ css_classes: item.css_classes,
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
+ translation_group: item.translation_group ?? item.id,
+ };
+ }),
+ )
+ .execute();
+ }
+ }
+ });
const menu = await db
.selectFrom("_emdash_menus")
.selectAll()
.where("id", "=", id)
.executeTakeFirstOrThrow();
-
return { success: true, data: menu };
} catch {
return {
@@ -126,18 +231,18 @@ export async function handleMenuCreate(
}
/**
- * Get a single menu with all its items.
+ * Get a single menu by name. Honours an optional `locale` filter; when two
+ * menus share a name across locales, the locale distinguishes them.
*/
export async function handleMenuGet(
db: Kysely,
name: string,
+ options: { locale?: string } = {},
): Promise> {
try {
- const menu = await db
- .selectFrom("_emdash_menus")
- .selectAll()
- .where("name", "=", name)
- .executeTakeFirst();
+ let query = db.selectFrom("_emdash_menus").selectAll().where("name", "=", name);
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
+ const menu = await query.orderBy("locale", "asc").executeTakeFirst();
if (!menu) {
return {
@@ -163,19 +268,52 @@ export async function handleMenuGet(
}
/**
- * Update a menu's metadata.
+ * Get a menu by id. Useful when the caller already has the id (e.g. after
+ * creating a translation and navigating to it).
*/
-export async function handleMenuUpdate(
+export async function handleMenuGetById(
db: Kysely,
- name: string,
- input: { label?: string },
-): Promise> {
+ id: string,
+): Promise> {
try {
const menu = await db
.selectFrom("_emdash_menus")
- .select("id")
- .where("name", "=", name)
+ .selectAll()
+ .where("id", "=", id)
.executeTakeFirst();
+ if (!menu) {
+ return {
+ success: false,
+ error: { code: "NOT_FOUND", message: `Menu '${id}' not found` },
+ };
+ }
+ const items = await db
+ .selectFrom("_emdash_menu_items")
+ .selectAll()
+ .where("menu_id", "=", menu.id)
+ .orderBy("sort_order", "asc")
+ .execute();
+ return { success: true, data: { ...menu, items } };
+ } catch {
+ return {
+ success: false,
+ error: { code: "MENU_GET_ERROR", message: "Failed to fetch menu" },
+ };
+ }
+}
+
+/**
+ * Update a menu's label. The name + locale are immutable.
+ */
+export async function handleMenuUpdate(
+ db: Kysely,
+ name: string,
+ input: { label?: string; locale?: string },
+): Promise> {
+ try {
+ let query = db.selectFrom("_emdash_menus").select("id").where("name", "=", name);
+ if (input.locale !== undefined) query = query.where("locale", "=", input.locale);
+ const menu = await query.executeTakeFirst();
if (!menu) {
return {
@@ -197,7 +335,6 @@ export async function handleMenuUpdate(
.selectAll()
.where("id", "=", menu.id)
.executeTakeFirstOrThrow();
-
return { success: true, data: updated };
} catch {
return {
@@ -208,18 +345,17 @@ export async function handleMenuUpdate(
}
/**
- * Delete a menu and its items (cascade).
+ * Delete a menu (and items, via cascade).
*/
export async function handleMenuDelete(
db: Kysely,
name: string,
+ options: { locale?: string } = {},
): Promise> {
try {
- const menu = await db
- .selectFrom("_emdash_menus")
- .select("id")
- .where("name", "=", name)
- .executeTakeFirst();
+ let query = db.selectFrom("_emdash_menus").select("id").where("name", "=", name);
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
+ const menu = await query.executeTakeFirst();
if (!menu) {
return {
@@ -233,7 +369,6 @@ export async function handleMenuDelete(
// idempotent on SQLite/Postgres where the cascade also fires.
await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
await db.deleteFrom("_emdash_menus").where("id", "=", menu.id).execute();
-
return { success: true, data: { deleted: true } };
} catch {
return {
@@ -243,6 +378,53 @@ export async function handleMenuDelete(
}
}
+/**
+ * List every translation of a menu (by id or translation_group).
+ */
+export async function handleMenuTranslations(
+ db: Kysely,
+ idOrGroup: string,
+): Promise> {
+ try {
+ const anchor = await db
+ .selectFrom("_emdash_menus")
+ .selectAll()
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
+ .executeTakeFirst();
+ if (!anchor) {
+ return {
+ success: false,
+ error: { code: "NOT_FOUND", message: "Menu not found" },
+ };
+ }
+ const group = anchor.translation_group ?? anchor.id;
+ const rows = await db
+ .selectFrom("_emdash_menus")
+ .selectAll()
+ .where("translation_group", "=", group)
+ .orderBy("locale", "asc")
+ .execute();
+ return {
+ success: true,
+ data: {
+ translationGroup: group,
+ translations: rows.map((row) => ({
+ id: row.id,
+ name: row.name,
+ locale: row.locale,
+ label: row.label,
+ updatedAt: row.updated_at,
+ })),
+ },
+ };
+ } catch {
+ return {
+ success: false,
+ error: { code: "MENU_TRANSLATIONS_ERROR", message: "Failed to list menu translations" },
+ };
+ }
+}
+
// ---------------------------------------------------------------------------
// Menu item handlers
// ---------------------------------------------------------------------------
@@ -261,19 +443,22 @@ export interface CreateMenuItemInput {
}
/**
- * Add an item to a menu.
+ * Add an item to a menu. The item inherits the menu's locale (so listing
+ * items by locale stays trivial).
*/
export async function handleMenuItemCreate(
db: Kysely,
menuName: string,
input: CreateMenuItemInput,
+ options: { locale?: string } = {},
): Promise> {
try {
- const menu = await db
+ let menuQuery = db
.selectFrom("_emdash_menus")
- .select("id")
- .where("name", "=", menuName)
- .executeTakeFirst();
+ .select(["id", "locale"])
+ .where("name", "=", menuName);
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
+ const menu = await menuQuery.executeTakeFirst();
if (!menu) {
return {
@@ -290,7 +475,6 @@ export async function handleMenuItemCreate(
.where("menu_id", "=", menu.id)
.where("parent_id", "is", input.parentId ?? null)
.executeTakeFirst();
-
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely fn.max returns unknown; always a number for sort_order column
sortOrder = ((maxOrder?.max as number) ?? -1) + 1;
}
@@ -311,6 +495,8 @@ export async function handleMenuItemCreate(
title_attr: input.titleAttr ?? null,
target: input.target ?? null,
css_classes: input.cssClasses ?? null,
+ locale: menu.locale,
+ translation_group: id,
})
.execute();
@@ -319,7 +505,6 @@ export async function handleMenuItemCreate(
.selectAll()
.where("id", "=", id)
.executeTakeFirstOrThrow();
-
return { success: true, data: item };
} catch {
return {
@@ -347,13 +532,12 @@ export async function handleMenuItemUpdate(
menuName: string,
itemId: string,
input: UpdateMenuItemInput,
+ options: { locale?: string } = {},
): Promise> {
try {
- const menu = await db
- .selectFrom("_emdash_menus")
- .select("id")
- .where("name", "=", menuName)
- .executeTakeFirst();
+ let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
+ const menu = await menuQuery.executeTakeFirst();
if (!menu) {
return {
@@ -394,7 +578,6 @@ export async function handleMenuItemUpdate(
.selectAll()
.where("id", "=", itemId)
.executeTakeFirstOrThrow();
-
return { success: true, data: updated };
} catch {
return {
@@ -411,13 +594,12 @@ export async function handleMenuItemDelete(
db: Kysely,
menuName: string,
itemId: string,
+ options: { locale?: string } = {},
): Promise> {
try {
- const menu = await db
- .selectFrom("_emdash_menus")
- .select("id")
- .where("name", "=", menuName)
- .executeTakeFirst();
+ let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
+ const menu = await menuQuery.executeTakeFirst();
if (!menu) {
return {
@@ -589,13 +771,12 @@ export async function handleMenuItemReorder(
db: Kysely,
menuName: string,
items: ReorderItem[],
+ options: { locale?: string } = {},
): Promise> {
try {
- const menu = await db
- .selectFrom("_emdash_menus")
- .select("id")
- .where("name", "=", menuName)
- .executeTakeFirst();
+ let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
+ const menu = await menuQuery.executeTakeFirst();
if (!menu) {
return {
diff --git a/packages/core/src/api/handlers/taxonomies.ts b/packages/core/src/api/handlers/taxonomies.ts
index e8b5b26a9..9bce9398c 100644
--- a/packages/core/src/api/handlers/taxonomies.ts
+++ b/packages/core/src/api/handlers/taxonomies.ts
@@ -1,16 +1,20 @@
/**
- * Taxonomy and term CRUD handlers
+ * Taxonomy and term CRUD handlers.
+ *
+ * i18n: terms and defs are per-locale. `(name, slug, locale)` is unique for
+ * terms; `(name, locale)` for defs. Translations of the same term/def share a
+ * `translation_group`. The content_taxonomies pivot stores translation_groups
+ * so assignments span every locale of a post.
*/
-import type { Kysely } from "kysely";
+import type { Kysely, Selectable } from "kysely";
import { ulid } from "ulidx";
import { TaxonomyRepository } from "../../database/repositories/taxonomy.js";
-import type { Database } from "../../database/types.js";
+import type { Database, TaxonomyDefTable } from "../../database/types.js";
import { invalidateTermCache } from "../../taxonomies/index.js";
import type { ApiResult } from "../types.js";
-/** Taxonomy name validation pattern: lowercase alphanumeric + underscores, starts with letter */
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
// ---------------------------------------------------------------------------
@@ -24,6 +28,8 @@ export interface TaxonomyDef {
labelSingular?: string;
hierarchical: boolean;
collections: string[];
+ locale: string;
+ translationGroup: string | null;
}
export interface TaxonomyListResponse {
@@ -37,6 +43,8 @@ export interface TermData {
label: string;
parentId: string | null;
description?: string;
+ locale: string;
+ translationGroup: string | null;
}
export interface TermWithCount extends TermData {
@@ -59,6 +67,26 @@ export interface TermGetResponse {
};
}
+export interface TermTranslationsResponse {
+ translationGroup: string | null;
+ translations: Array<{
+ id: string;
+ slug: string;
+ label: string;
+ locale: string;
+ }>;
+}
+
+export interface TaxonomyDefTranslationsResponse {
+ translationGroup: string | null;
+ translations: Array<{
+ id: string;
+ name: string;
+ label: string;
+ locale: string;
+ }>;
+}
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -69,11 +97,7 @@ export interface TermGetResponse {
function buildTree(flatTerms: TermWithCount[]): TermWithCount[] {
const map = new Map();
const roots: TermWithCount[] = [];
-
- for (const term of flatTerms) {
- map.set(term.id, term);
- }
-
+ for (const term of flatTerms) map.set(term.id, term);
for (const term of flatTerms) {
if (term.parentId && map.has(term.parentId)) {
map.get(term.parentId)!.children.push(term);
@@ -81,38 +105,48 @@ function buildTree(flatTerms: TermWithCount[]): TermWithCount[] {
roots.push(term);
}
}
-
return roots;
}
/**
- * Look up a taxonomy definition by name, returning a NOT_FOUND error if missing.
+ * Look up a taxonomy definition by name (optionally scoped to a locale).
+ * Returns the lowest-locale match when no locale is provided.
*/
async function requireTaxonomyDef(
db: Kysely,
name: string,
+ locale?: string,
): Promise<
- | { success: true; def: { hierarchical: number } }
+ | { success: true; def: Selectable }
| { success: false; error: { code: string; message: string } }
> {
- const def = await db
- .selectFrom("_emdash_taxonomy_defs")
- .selectAll()
- .where("name", "=", name)
- .executeTakeFirst();
-
+ let query = db.selectFrom("_emdash_taxonomy_defs").selectAll().where("name", "=", name);
+ if (locale !== undefined) query = query.where("locale", "=", locale);
+ const def = await query.orderBy("locale", "asc").executeTakeFirst();
if (!def) {
return {
success: false,
error: { code: "NOT_FOUND", message: `Taxonomy '${name}' not found` },
};
}
-
return { success: true, def };
}
+function rowToDef(row: Selectable): TaxonomyDef {
+ return {
+ id: row.id,
+ name: row.name,
+ label: row.label,
+ labelSingular: row.label_singular ?? undefined,
+ hierarchical: row.hierarchical === 1,
+ collections: row.collections ? JSON.parse(row.collections) : [],
+ locale: row.locale,
+ translationGroup: row.translation_group,
+ };
+}
+
// ---------------------------------------------------------------------------
-// Handlers
+// Taxonomy definition handlers
// ---------------------------------------------------------------------------
/**
@@ -120,10 +154,13 @@ async function requireTaxonomyDef(
*/
export async function handleTaxonomyList(
db: Kysely,
+ options: { locale?: string } = {},
): Promise> {
try {
+ let query = db.selectFrom("_emdash_taxonomy_defs").selectAll();
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
const [rows, collectionRows] = await Promise.all([
- db.selectFrom("_emdash_taxonomy_defs").selectAll().execute(),
+ query.execute(),
db.selectFrom("_emdash_collections").select("slug").execute(),
]);
@@ -133,15 +170,8 @@ export async function handleTaxonomyList(
const realCollections = new Set(collectionRows.map((r) => r.slug));
const taxonomies: TaxonomyDef[] = rows.map((row) => {
- const stored: string[] = row.collections ? JSON.parse(row.collections) : [];
- return {
- id: row.id,
- name: row.name,
- label: row.label,
- labelSingular: row.label_singular ?? undefined,
- hierarchical: row.hierarchical === 1,
- collections: stored.filter((slug) => realCollections.has(slug)),
- };
+ const def = rowToDef(row);
+ return { ...def, collections: def.collections.filter((slug) => realCollections.has(slug)) };
});
return { success: true, data: { taxonomies } };
@@ -158,10 +188,17 @@ export async function handleTaxonomyList(
*/
export async function handleTaxonomyCreate(
db: Kysely,
- input: { name: string; label: string; hierarchical?: boolean; collections?: string[] },
+ input: {
+ name: string;
+ label: string;
+ labelSingular?: string;
+ hierarchical?: boolean;
+ collections?: string[];
+ locale?: string;
+ translationOf?: string;
+ },
): Promise> {
try {
- // Validate name format
if (!NAME_PATTERN.test(input.name)) {
return {
success: false,
@@ -174,15 +211,12 @@ export async function handleTaxonomyCreate(
}
const collections = [...new Set(input.collections ?? [])];
-
- // Validate that referenced collections exist
if (collections.length > 0) {
const existingCollections = await db
.selectFrom("_emdash_collections")
.select("slug")
.where("slug", "in", collections)
.execute();
-
const existingSlugs = new Set(existingCollections.map((c) => c.slug));
const invalid = collections.filter((c) => !existingSlugs.has(c));
if (invalid.length > 0) {
@@ -196,58 +230,68 @@ export async function handleTaxonomyCreate(
}
}
- // Check for duplicate name
- const existing = await db
- .selectFrom("_emdash_taxonomy_defs")
- .selectAll()
- .where("name", "=", input.name)
- .executeTakeFirst();
+ let translationGroup: string | null = null;
+ if (input.translationOf) {
+ const source = await db
+ .selectFrom("_emdash_taxonomy_defs")
+ .selectAll()
+ .where("id", "=", input.translationOf)
+ .executeTakeFirst();
+ if (!source) {
+ return {
+ success: false,
+ error: { code: "NOT_FOUND", message: "Source taxonomy for translation not found" },
+ };
+ }
+ translationGroup = source.translation_group ?? source.id;
+ }
- if (existing) {
- return {
- success: false,
- error: {
- code: "CONFLICT",
- message: `Taxonomy '${input.name}' already exists`,
- },
- };
+ // Duplicate guard scoped to locale (so the same name can exist in ES
+ // and EN).
+ if (input.locale !== undefined) {
+ const existing = await db
+ .selectFrom("_emdash_taxonomy_defs")
+ .select("id")
+ .where("name", "=", input.name)
+ .where("locale", "=", input.locale)
+ .executeTakeFirst();
+ if (existing) {
+ return {
+ success: false,
+ error: {
+ code: "CONFLICT",
+ message: `Taxonomy '${input.name}' already exists in locale '${input.locale}'`,
+ },
+ };
+ }
}
const id = ulid();
-
await db
.insertInto("_emdash_taxonomy_defs")
.values({
id,
name: input.name,
label: input.label,
- label_singular: null,
+ label_singular: input.labelSingular ?? null,
hierarchical: input.hierarchical ? 1 : 0,
collections: JSON.stringify(collections),
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
+ translation_group: translationGroup ?? id,
})
.execute();
- return {
- success: true,
- data: {
- taxonomy: {
- id,
- name: input.name,
- label: input.label,
- hierarchical: input.hierarchical ?? false,
- collections,
- },
- },
- };
+ const row = await db
+ .selectFrom("_emdash_taxonomy_defs")
+ .selectAll()
+ .where("id", "=", id)
+ .executeTakeFirstOrThrow();
+ return { success: true, data: { taxonomy: rowToDef(row) } };
} catch (error) {
- // Handle UNIQUE constraint violation from concurrent duplicate inserts
if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
return {
success: false,
- error: {
- code: "CONFLICT",
- message: `Taxonomy '${input.name}' already exists`,
- },
+ error: { code: "CONFLICT", message: `Taxonomy '${input.name}' already exists` },
};
}
return {
@@ -257,23 +301,81 @@ export async function handleTaxonomyCreate(
}
}
+/**
+ * List every locale translation of a taxonomy def (by id or translation_group).
+ */
+export async function handleTaxonomyDefTranslations(
+ db: Kysely,
+ idOrGroup: string,
+): Promise> {
+ try {
+ const anchor = await db
+ .selectFrom("_emdash_taxonomy_defs")
+ .selectAll()
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
+ .executeTakeFirst();
+ if (!anchor) {
+ return {
+ success: false,
+ error: { code: "NOT_FOUND", message: "Taxonomy not found" },
+ };
+ }
+ const group = anchor.translation_group ?? anchor.id;
+ const rows = await db
+ .selectFrom("_emdash_taxonomy_defs")
+ .selectAll()
+ .where("translation_group", "=", group)
+ .orderBy("locale", "asc")
+ .execute();
+ return {
+ success: true,
+ data: {
+ translationGroup: group,
+ translations: rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ label: r.label,
+ locale: r.locale,
+ })),
+ },
+ };
+ } catch {
+ return {
+ success: false,
+ error: {
+ code: "TAXONOMY_TRANSLATIONS_ERROR",
+ message: "Failed to list taxonomy translations",
+ },
+ };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Term handlers
+// ---------------------------------------------------------------------------
+
/**
* List all terms for a taxonomy (returns tree for hierarchical taxonomies)
*/
export async function handleTermList(
db: Kysely,
taxonomyName: string,
+ options: { locale?: string } = {},
): Promise> {
try {
+ // Definitions are per-locale but terms aren't bound to the def's locale —
+ // just ensure the taxonomy exists somewhere.
const lookup = await requireTaxonomyDef(db, taxonomyName);
if (!lookup.success) return lookup;
const repo = new TaxonomyRepository(db);
- const terms = await repo.findByName(taxonomyName);
+ const terms = await repo.findByName(taxonomyName, { locale: options.locale });
- // Batch count entries per term in a single query (replaces N+1 pattern)
- const termIds = terms.map((t) => t.id);
- const counts = await repo.countEntriesForTerms(termIds);
+ // Batch count entries per term in a single query (replaces N+1 pattern).
+ // content_taxonomies.taxonomy_id stores the translation_group, so we
+ // look up by group and map back to each term's id.
+ const groups = terms.map((t) => t.translationGroup ?? t.id);
+ const countsByGroup = await repo.countEntriesForTerms(groups);
const termData: TermWithCount[] = terms.map((term) => ({
id: term.id,
@@ -283,12 +385,13 @@ export async function handleTermList(
parentId: term.parentId,
description: typeof term.data?.description === "string" ? term.data.description : undefined,
children: [],
- count: counts.get(term.id) ?? 0,
+ count: countsByGroup.get(term.translationGroup ?? term.id) ?? 0,
+ locale: term.locale,
+ translationGroup: term.translationGroup,
}));
const isHierarchical = lookup.def.hierarchical === 1;
const result = isHierarchical ? buildTree(termData) : termData;
-
return { success: true, data: { terms: result } };
} catch {
return {
@@ -382,30 +485,60 @@ async function validateParentTerm(
export async function handleTermCreate(
db: Kysely,
taxonomyName: string,
- input: { slug: string; label: string; parentId?: string | null; description?: string },
+ input: {
+ slug: string;
+ label: string;
+ parentId?: string | null;
+ description?: string;
+ locale?: string;
+ translationOf?: string;
+ },
): Promise> {
try {
+ // Taxonomy definitions are per-locale, but terms can exist in any locale
+ // regardless of whether the def has been translated there. Look up the
+ // def across all locales — we only care that it *exists*.
const lookup = await requireTaxonomyDef(db, taxonomyName);
if (!lookup.success) return lookup;
const repo = new TaxonomyRepository(db);
// Coerce empty-string parentId to undefined (treat as "no parent").
- const parentId =
+ let parentId =
input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
- // Check for slug conflict
- const existing = await repo.findBySlug(taxonomyName, input.slug);
+ // Conflict check is scoped to locale (per-locale slugs are unique).
+ const existing = await repo.findBySlug(taxonomyName, input.slug, input.locale);
if (existing) {
return {
success: false,
error: {
code: "CONFLICT",
- message: `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,
+ message: input.locale
+ ? `Term '${input.slug}' already exists in '${taxonomyName}' (${input.locale})`
+ : `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,
},
};
}
+ // If creating a translation whose parent is the translated sibling of
+ // the source's parent, try to resolve the parent in the same locale.
+ if (input.translationOf && parentId) {
+ const source = await repo.findById(input.translationOf);
+ if (source?.parentId === parentId && input.locale) {
+ const sourceParent = await repo.findById(parentId);
+ if (sourceParent?.translationGroup) {
+ const translatedParent = await db
+ .selectFrom("taxonomies")
+ .select("id")
+ .where("translation_group", "=", sourceParent.translationGroup)
+ .where("locale", "=", input.locale)
+ .executeTakeFirst();
+ if (translatedParent) parentId = translatedParent.id;
+ }
+ }
+ }
+
// Validate parentId: must exist AND belong to the same taxonomy.
// (Cycle check is N/A on create — the term doesn't exist yet.)
const parentError = await validateParentTerm(repo, taxonomyName, undefined, parentId);
@@ -419,10 +552,10 @@ export async function handleTermCreate(
label: input.label,
parentId: parentId ?? undefined,
data: input.description ? { description: input.description } : undefined,
+ locale: input.locale,
+ translationOf: input.translationOf,
});
- // New term means `hasAnyTermAssignments` may flip from false->true next
- // time an entry is tagged. Clear the cache so the next read re-probes.
invalidateTermCache();
return {
@@ -436,6 +569,8 @@ export async function handleTermCreate(
parentId: term.parentId,
description:
typeof term.data?.description === "string" ? term.data.description : undefined,
+ locale: term.locale,
+ translationGroup: term.translationGroup,
},
},
};
@@ -454,10 +589,11 @@ export async function handleTermGet(
db: Kysely,
taxonomyName: string,
termSlug: string,
+ options: { locale?: string } = {},
): Promise> {
try {
const repo = new TaxonomyRepository(db);
- const term = await repo.findBySlug(taxonomyName, termSlug);
+ const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
if (!term) {
return {
@@ -484,11 +620,9 @@ export async function handleTermGet(
description:
typeof term.data?.description === "string" ? term.data.description : undefined,
count,
- children: children.map((c) => ({
- id: c.id,
- slug: c.slug,
- label: c.label,
- })),
+ children: children.map((c) => ({ id: c.id, slug: c.slug, label: c.label })),
+ locale: term.locale,
+ translationGroup: term.translationGroup,
},
},
};
@@ -500,6 +634,50 @@ export async function handleTermGet(
}
}
+/** List every translation of a term (by id or translation_group). */
+export async function handleTermTranslations(
+ db: Kysely,
+ idOrGroup: string,
+): Promise> {
+ try {
+ const anchor = await db
+ .selectFrom("taxonomies")
+ .selectAll()
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
+ .executeTakeFirst();
+ if (!anchor) {
+ return {
+ success: false,
+ error: { code: "NOT_FOUND", message: "Term not found" },
+ };
+ }
+ const group = anchor.translation_group ?? anchor.id;
+ const rows = await db
+ .selectFrom("taxonomies")
+ .selectAll()
+ .where("translation_group", "=", group)
+ .orderBy("locale", "asc")
+ .execute();
+ return {
+ success: true,
+ data: {
+ translationGroup: group,
+ translations: rows.map((r) => ({
+ id: r.id,
+ slug: r.slug,
+ label: r.label,
+ locale: r.locale,
+ })),
+ },
+ };
+ } catch {
+ return {
+ success: false,
+ error: { code: "TERM_TRANSLATIONS_ERROR", message: "Failed to list term translations" },
+ };
+ }
+}
+
/**
* Update a term
*/
@@ -508,10 +686,11 @@ export async function handleTermUpdate(
taxonomyName: string,
termSlug: string,
input: { slug?: string; label?: string; parentId?: string | null; description?: string },
+ options: { locale?: string } = {},
): Promise> {
try {
const repo = new TaxonomyRepository(db);
- const term = await repo.findBySlug(taxonomyName, termSlug);
+ const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
if (!term) {
return {
@@ -529,9 +708,9 @@ export async function handleTermUpdate(
const newParentId =
input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
- // Check if new slug conflicts
+ // Check if new slug conflicts (per-locale uniqueness).
if (newSlug !== undefined && newSlug !== termSlug) {
- const existing = await repo.findBySlug(taxonomyName, newSlug);
+ const existing = await repo.findBySlug(taxonomyName, newSlug, options.locale);
if (existing && existing.id !== term.id) {
return {
success: false,
@@ -556,8 +735,6 @@ export async function handleTermUpdate(
data: input.description !== undefined ? { description: input.description } : undefined,
});
- // Term label/slug changes are reflected in hydrated entry.data.terms —
- // invalidate so the next read doesn't short-circuit on a stale probe.
invalidateTermCache();
if (!updated) {
@@ -578,6 +755,8 @@ export async function handleTermUpdate(
parentId: updated.parentId,
description:
typeof updated.data?.description === "string" ? updated.data.description : undefined,
+ locale: updated.locale,
+ translationGroup: updated.translationGroup,
},
},
};
@@ -596,10 +775,11 @@ export async function handleTermDelete(
db: Kysely,
taxonomyName: string,
termSlug: string,
+ options: { locale?: string } = {},
): Promise> {
try {
const repo = new TaxonomyRepository(db);
- const term = await repo.findBySlug(taxonomyName, termSlug);
+ const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
if (!term) {
return {
@@ -611,7 +791,6 @@ export async function handleTermDelete(
};
}
- // Prevent deletion of terms with children
const children = await repo.findChildren(term.id);
if (children.length > 0) {
return {
@@ -631,10 +810,7 @@ export async function handleTermDelete(
};
}
- // Deleting a term cascades to content_taxonomies; invalidate so
- // hydration no longer sees the stale assignments.
invalidateTermCache();
-
return { success: true, data: { deleted: true } };
} catch {
return {
diff --git a/packages/core/src/api/schemas/common.ts b/packages/core/src/api/schemas/common.ts
index 869f0fb9d..292655381 100644
--- a/packages/core/src/api/schemas/common.ts
+++ b/packages/core/src/api/schemas/common.ts
@@ -59,6 +59,13 @@ export const localeCode = z
.regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code")
.transform((v) => v.toLowerCase());
+/** Shared `?locale=xx` query shape for endpoints that filter by locale. */
+export const localeFilterQuery = z
+ .object({
+ locale: z.string().min(1).optional(),
+ })
+ .meta({ id: "LocaleFilterQuery" });
+
// ---------------------------------------------------------------------------
// OpenAPI: Shared response schemas
// ---------------------------------------------------------------------------
diff --git a/packages/core/src/api/schemas/menus.ts b/packages/core/src/api/schemas/menus.ts
index 8a1a8dc44..848331077 100644
--- a/packages/core/src/api/schemas/menus.ts
+++ b/packages/core/src/api/schemas/menus.ts
@@ -20,6 +20,10 @@ export const createMenuBody = z
.object({
name: z.string().min(1),
label: z.string().min(1),
+ locale: z.string().min(1).optional(),
+ /** When set, clones the items from the source menu. The new menu joins
+ * the source's translation_group. */
+ translationOf: z.string().min(1).optional(),
})
.meta({ id: "CreateMenuBody" });
@@ -87,6 +91,8 @@ export const menuSchema = z
label: z.string(),
created_at: z.string(),
updated_at: z.string(),
+ locale: z.string(),
+ translation_group: z.string().nullable(),
})
.meta({ id: "Menu" });
@@ -105,9 +111,26 @@ export const menuItemSchema = z
target: z.string().nullable(),
css_classes: z.string().nullable(),
created_at: z.string(),
+ locale: z.string(),
+ translation_group: z.string().nullable(),
})
.meta({ id: "MenuItem" });
+export const menuTranslationsSchema = z
+ .object({
+ translationGroup: z.string().nullable(),
+ translations: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ label: z.string(),
+ locale: z.string(),
+ updatedAt: z.string(),
+ }),
+ ),
+ })
+ .meta({ id: "MenuTranslations" });
+
export const menuListItemSchema = menuSchema
.extend({
itemCount: z.number().int(),
diff --git a/packages/core/src/api/schemas/taxonomies.ts b/packages/core/src/api/schemas/taxonomies.ts
index 09a582caa..71e2fff28 100644
--- a/packages/core/src/api/schemas/taxonomies.ts
+++ b/packages/core/src/api/schemas/taxonomies.ts
@@ -15,6 +15,7 @@ export const createTaxonomyDefBody = z
.max(63)
.regex(/^[a-z][a-z0-9_]*$/, "Name must be lowercase alphanumeric with underscores"),
label: z.string().min(1).max(200),
+ labelSingular: z.string().min(1).max(200).optional(),
hierarchical: z.boolean().optional().default(false),
collections: z
.array(
@@ -23,6 +24,8 @@ export const createTaxonomyDefBody = z
.max(100)
.optional()
.default([]),
+ locale: z.string().min(1).optional(),
+ translationOf: z.string().min(1).optional(),
})
.meta({ id: "CreateTaxonomyDefBody" });
@@ -36,6 +39,8 @@ export const createTermBody = z
label: z.string().min(1),
parentId: z.string().nullish(),
description: z.string().optional(),
+ locale: z.string().min(1).optional(),
+ translationOf: z.string().min(1).optional(),
})
.meta({ id: "CreateTermBody" });
@@ -60,9 +65,25 @@ export const taxonomyDefSchema = z
labelSingular: z.string().optional(),
hierarchical: z.boolean(),
collections: z.array(z.string()),
+ locale: z.string(),
+ translationGroup: z.string().nullable(),
})
.meta({ id: "TaxonomyDef" });
+export const taxonomyDefTranslationsSchema = z
+ .object({
+ translationGroup: z.string().nullable(),
+ translations: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ label: z.string(),
+ locale: z.string(),
+ }),
+ ),
+ })
+ .meta({ id: "TaxonomyDefTranslations" });
+
export const taxonomyListResponseSchema = z
.object({ taxonomies: z.array(taxonomyDefSchema) })
.meta({ id: "TaxonomyListResponse" });
@@ -75,9 +96,25 @@ export const termSchema = z
label: z.string(),
parentId: z.string().nullable(),
description: z.string().optional(),
+ locale: z.string(),
+ translationGroup: z.string().nullable(),
})
.meta({ id: "Term" });
+export const termTranslationsSchema = z
+ .object({
+ translationGroup: z.string().nullable(),
+ translations: z.array(
+ z.object({
+ id: z.string(),
+ slug: z.string(),
+ label: z.string(),
+ locale: z.string(),
+ }),
+ ),
+ })
+ .meta({ id: "TermTranslations" });
+
export const termWithCountSchema: z.ZodType = z
.object({
id: z.string(),
@@ -88,6 +125,8 @@ export const termWithCountSchema: z.ZodType = z
description: z.string().optional(),
count: z.number().int(),
children: z.array(z.lazy(() => termWithCountSchema)),
+ locale: z.string(),
+ translationGroup: z.string().nullable(),
})
.meta({ id: "TermWithCount" });
diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts
index 5f2f002f8..f01f9a907 100644
--- a/packages/core/src/astro/integration/routes.ts
+++ b/packages/core/src/astro/integration/routes.ts
@@ -313,6 +313,11 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void {
entrypoint: resolveRoute("api/taxonomies/[name]/terms/[slug].ts"),
});
+ injectRoute({
+ pattern: "/_emdash/api/taxonomies/[name]/terms/[slug]/translations",
+ entrypoint: resolveRoute("api/taxonomies/[name]/terms/[slug]/translations.ts"),
+ });
+
injectRoute({
pattern: "/_emdash/api/content/[collection]/[id]/terms/[taxonomy]",
entrypoint: resolveRoute("api/content/[collection]/[id]/terms/[taxonomy].ts"),
@@ -555,6 +560,11 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void {
entrypoint: resolveRoute("api/menus/[name]/reorder.ts"),
});
+ injectRoute({
+ pattern: "/_emdash/api/menus/[name]/translations",
+ entrypoint: resolveRoute("api/menus/[name]/translations.ts"),
+ });
+
// Widget area routes
injectRoute({
pattern: "/_emdash/api/widget-areas",
diff --git a/packages/core/src/astro/routes/api/menus/[name].ts b/packages/core/src/astro/routes/api/menus/[name].ts
index d6ef46c25..9c63c0126 100644
--- a/packages/core/src/astro/routes/api/menus/[name].ts
+++ b/packages/core/src/astro/routes/api/menus/[name].ts
@@ -1,9 +1,9 @@
/**
* Single menu endpoint
*
- * GET /_emdash/api/menus/:name - Get menu with items
- * PUT /_emdash/api/menus/:name - Update menu metadata
- * DELETE /_emdash/api/menus/:name - Delete menu
+ * GET /_emdash/api/menus/:name[?locale=xx]
+ * PUT /_emdash/api/menus/:name[?locale=xx]
+ * DELETE /_emdash/api/menus/:name[?locale=xx]
*/
import type { APIRoute } from "astro";
@@ -11,20 +11,23 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { handleError, unwrapResult } from "#api/error.js";
import { handleMenuDelete, handleMenuGet, handleMenuUpdate } from "#api/handlers/menus.js";
-import { isParseError, parseBody } from "#api/parse.js";
-import { updateMenuBody } from "#api/schemas.js";
+import { isParseError, parseBody, parseQuery } from "#api/parse.js";
+import { localeFilterQuery, updateMenuBody } from "#api/schemas.js";
export const prerender = false;
-export const GET: APIRoute = async ({ params, locals }) => {
+export const GET: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const name = params.name!;
const denied = requirePerm(user, "menus:read");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
- const result = await handleMenuGet(emdash.db, name);
+ const result = await handleMenuGet(emdash.db, name, { locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to fetch menu", "MENU_GET_ERROR");
@@ -38,26 +41,32 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
const denied = requirePerm(user, "menus:manage");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
const body = await parseBody(request, updateMenuBody);
if (isParseError(body)) return body;
- const result = await handleMenuUpdate(emdash.db, name, body);
+ const result = await handleMenuUpdate(emdash.db, name, { ...body, locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to update menu", "MENU_UPDATE_ERROR");
}
};
-export const DELETE: APIRoute = async ({ params, locals }) => {
+export const DELETE: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const name = params.name!;
const denied = requirePerm(user, "menus:manage");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
- const result = await handleMenuDelete(emdash.db, name);
+ const result = await handleMenuDelete(emdash.db, name, { locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to delete menu", "MENU_DELETE_ERROR");
diff --git a/packages/core/src/astro/routes/api/menus/[name]/items.ts b/packages/core/src/astro/routes/api/menus/[name]/items.ts
index 8e25ef9c5..1e42c2dd8 100644
--- a/packages/core/src/astro/routes/api/menus/[name]/items.ts
+++ b/packages/core/src/astro/routes/api/menus/[name]/items.ts
@@ -1,9 +1,9 @@
/**
* Menu items CRUD endpoints
*
- * POST /_emdash/api/menus/:name/items - Add item
- * PUT /_emdash/api/menus/:name/items/:id - Update item
- * DELETE /_emdash/api/menus/:name/items/:id - Delete item
+ * POST /_emdash/api/menus/:name/items[?locale=xx]
+ * PUT /_emdash/api/menus/:name/items?id=...[&locale=xx]
+ * DELETE /_emdash/api/menus/:name/items?id=...[&locale=xx]
*/
import type { APIRoute } from "astro";
@@ -18,6 +18,7 @@ import {
import { isParseError, parseBody, parseQuery } from "#api/parse.js";
import {
createMenuItemBody,
+ localeFilterQuery,
menuItemDeleteQuery,
menuItemUpdateQuery,
updateMenuItemBody,
@@ -32,11 +33,14 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
const denied = requirePerm(user, "menus:manage");
if (denied) return denied;
+ const localeQ = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(localeQ)) return localeQ;
+
try {
const body = await parseBody(request, createMenuItemBody);
if (isParseError(body)) return body;
- const result = await handleMenuItemCreate(emdash.db, name, body);
+ const result = await handleMenuItemCreate(emdash.db, name, body, { locale: localeQ.locale });
return unwrapResult(result, 201);
} catch (error) {
return handleError(error, "Failed to create menu item", "MENU_ITEM_CREATE_ERROR");
@@ -53,13 +57,17 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
const url = new URL(request.url);
const query = parseQuery(url, menuItemUpdateQuery);
if (isParseError(query)) return query;
+ const localeQ = parseQuery(url, localeFilterQuery);
+ if (isParseError(localeQ)) return localeQ;
const itemId = query.id;
try {
const body = await parseBody(request, updateMenuItemBody);
if (isParseError(body)) return body;
- const result = await handleMenuItemUpdate(emdash.db, name, itemId, body);
+ const result = await handleMenuItemUpdate(emdash.db, name, itemId, body, {
+ locale: localeQ.locale,
+ });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to update menu item", "MENU_ITEM_UPDATE_ERROR");
@@ -76,10 +84,12 @@ export const DELETE: APIRoute = async ({ params, request, locals }) => {
const url = new URL(request.url);
const query = parseQuery(url, menuItemDeleteQuery);
if (isParseError(query)) return query;
+ const localeQ = parseQuery(url, localeFilterQuery);
+ if (isParseError(localeQ)) return localeQ;
const itemId = query.id;
try {
- const result = await handleMenuItemDelete(emdash.db, name, itemId);
+ const result = await handleMenuItemDelete(emdash.db, name, itemId, { locale: localeQ.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to delete menu item", "MENU_ITEM_DELETE_ERROR");
diff --git a/packages/core/src/astro/routes/api/menus/[name]/reorder.ts b/packages/core/src/astro/routes/api/menus/[name]/reorder.ts
index b2fba61de..236b04e5e 100644
--- a/packages/core/src/astro/routes/api/menus/[name]/reorder.ts
+++ b/packages/core/src/astro/routes/api/menus/[name]/reorder.ts
@@ -9,8 +9,8 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { handleError, unwrapResult } from "#api/error.js";
import { handleMenuItemReorder } from "#api/handlers/menus.js";
-import { isParseError, parseBody } from "#api/parse.js";
-import { reorderMenuItemsBody } from "#api/schemas.js";
+import { isParseError, parseBody, parseQuery } from "#api/parse.js";
+import { localeFilterQuery, reorderMenuItemsBody } from "#api/schemas.js";
export const prerender = false;
@@ -21,11 +21,16 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
const denied = requirePerm(user, "menus:manage");
if (denied) return denied;
+ const localeQ = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(localeQ)) return localeQ;
+
try {
const body = await parseBody(request, reorderMenuItemsBody);
if (isParseError(body)) return body;
- const result = await handleMenuItemReorder(emdash.db, name, body.items);
+ const result = await handleMenuItemReorder(emdash.db, name, body.items, {
+ locale: localeQ.locale,
+ });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to reorder menu items", "MENU_REORDER_ERROR");
diff --git a/packages/core/src/astro/routes/api/menus/[name]/translations.ts b/packages/core/src/astro/routes/api/menus/[name]/translations.ts
new file mode 100644
index 000000000..dcb684547
--- /dev/null
+++ b/packages/core/src/astro/routes/api/menus/[name]/translations.ts
@@ -0,0 +1,82 @@
+/**
+ * Menu translation endpoints
+ *
+ * GET /_emdash/api/menus/:name/translations — list translations for a menu (uses any locale row)
+ * POST /_emdash/api/menus/:name/translations — create a new locale translation (body: { locale, label })
+ */
+
+import type { APIRoute } from "astro";
+import { z } from "zod";
+
+import { requirePerm } from "#api/authorize.js";
+import { handleError, requireDb, unwrapResult } from "#api/error.js";
+import { handleMenuCreate, handleMenuGet, handleMenuTranslations } from "#api/handlers/menus.js";
+import { isParseError, parseBody, parseQuery } from "#api/parse.js";
+import { localeFilterQuery } from "#api/schemas.js";
+
+export const prerender = false;
+
+const createTranslationBody = z
+ .object({
+ locale: z.string().min(1),
+ label: z.string().min(1).optional(),
+ })
+ .meta({ id: "CreateMenuTranslationBody" });
+
+export const GET: APIRoute = async ({ params, request, locals }) => {
+ const { emdash, user } = locals;
+ const name = params.name!;
+
+ const dbErr = requireDb(emdash?.db);
+ if (dbErr) return dbErr;
+
+ const denied = requirePerm(user, "menus:read");
+ if (denied) return denied;
+
+ const localeQ = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(localeQ)) return localeQ;
+
+ try {
+ // Look up any menu row matching the name so we can get its translation_group.
+ const anchor = await handleMenuGet(emdash.db, name, { locale: localeQ.locale });
+ if (!anchor.success) return unwrapResult(anchor);
+ const result = await handleMenuTranslations(emdash.db, anchor.data.id);
+ return unwrapResult(result);
+ } catch (error) {
+ return handleError(error, "Failed to fetch menu translations", "MENU_TRANSLATIONS_ERROR");
+ }
+};
+
+export const POST: APIRoute = async ({ params, request, locals }) => {
+ const { emdash, user } = locals;
+ const name = params.name!;
+
+ const dbErr = requireDb(emdash?.db);
+ if (dbErr) return dbErr;
+
+ const denied = requirePerm(user, "menus:manage");
+ if (denied) return denied;
+
+ const localeQ = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(localeQ)) return localeQ;
+
+ try {
+ const body = await parseBody(request, createTranslationBody);
+ if (isParseError(body)) return body;
+
+ // Resolve the source menu (either by explicit locale in query, or the
+ // first matching row). Its id becomes the `translationOf` for the new row.
+ const source = await handleMenuGet(emdash.db, name, { locale: localeQ.locale });
+ if (!source.success) return unwrapResult(source);
+
+ const result = await handleMenuCreate(emdash.db, {
+ name,
+ label: body.label ?? source.data.label,
+ locale: body.locale,
+ translationOf: source.data.id,
+ });
+ return unwrapResult(result, 201);
+ } catch (error) {
+ return handleError(error, "Failed to create menu translation", "MENU_TRANSLATION_CREATE_ERROR");
+ }
+};
diff --git a/packages/core/src/astro/routes/api/menus/index.ts b/packages/core/src/astro/routes/api/menus/index.ts
index 44dd8e341..63e951eca 100644
--- a/packages/core/src/astro/routes/api/menus/index.ts
+++ b/packages/core/src/astro/routes/api/menus/index.ts
@@ -1,8 +1,8 @@
/**
* Menus list and create endpoints
*
- * GET /_emdash/api/menus - List all menus
- * POST /_emdash/api/menus - Create menu
+ * GET /_emdash/api/menus[?locale=xx] - List menus (optionally filtered by locale)
+ * POST /_emdash/api/menus - Create menu (body may include locale & translationOf)
*/
import type { APIRoute } from "astro";
@@ -10,19 +10,22 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { handleError, unwrapResult } from "#api/error.js";
import { handleMenuCreate, handleMenuList } from "#api/handlers/menus.js";
-import { isParseError, parseBody } from "#api/parse.js";
-import { createMenuBody } from "#api/schemas.js";
+import { isParseError, parseBody, parseQuery } from "#api/parse.js";
+import { createMenuBody, localeFilterQuery } from "#api/schemas.js";
export const prerender = false;
-export const GET: APIRoute = async ({ locals }) => {
+export const GET: APIRoute = async ({ request, locals }) => {
const { emdash, user } = locals;
const denied = requirePerm(user, "menus:read");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
- const result = await handleMenuList(emdash.db);
+ const result = await handleMenuList(emdash.db, { locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to fetch menus", "MENU_LIST_ERROR");
diff --git a/packages/core/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts b/packages/core/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts
index 8b2609895..7fd728c02 100644
--- a/packages/core/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts
+++ b/packages/core/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts
@@ -1,9 +1,9 @@
/**
* Single term endpoint
*
- * GET /_emdash/api/taxonomies/:name/terms/:slug - Get a single term
- * PUT /_emdash/api/taxonomies/:name/terms/:slug - Update a term
- * DELETE /_emdash/api/taxonomies/:name/terms/:slug - Delete a term
+ * GET /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
+ * PUT /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
+ * DELETE /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
*/
import type { APIRoute } from "astro";
@@ -11,21 +11,18 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
import { handleTermDelete, handleTermGet, handleTermUpdate } from "#api/handlers/taxonomies.js";
-import { isParseError, parseBody } from "#api/parse.js";
-import { updateTermBody } from "#api/schemas.js";
+import { isParseError, parseBody, parseQuery } from "#api/parse.js";
+import { localeFilterQuery, updateTermBody } from "#api/schemas.js";
export const prerender = false;
/**
* Get a single term
*/
-export const GET: APIRoute = async ({ params, locals }) => {
+export const GET: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const { name, slug } = params;
-
- if (!name || !slug) {
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
- }
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
const dbErr = requireDb(emdash?.db);
if (dbErr) return dbErr;
@@ -33,8 +30,11 @@ export const GET: APIRoute = async ({ params, locals }) => {
const denied = requirePerm(user, "taxonomies:read");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
- const result = await handleTermGet(emdash.db, name, slug);
+ const result = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to get term", "TERM_GET_ERROR");
@@ -47,10 +47,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
export const PUT: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const { name, slug } = params;
-
- if (!name || !slug) {
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
- }
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
const dbErr = requireDb(emdash?.db);
if (dbErr) return dbErr;
@@ -58,11 +55,14 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
const denied = requirePerm(user, "taxonomies:manage");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
const body = await parseBody(request, updateTermBody);
if (isParseError(body)) return body;
- const result = await handleTermUpdate(emdash.db, name, slug, body);
+ const result = await handleTermUpdate(emdash.db, name, slug, body, { locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to update term", "TERM_UPDATE_ERROR");
@@ -72,13 +72,10 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
/**
* Delete a term
*/
-export const DELETE: APIRoute = async ({ params, locals }) => {
+export const DELETE: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const { name, slug } = params;
-
- if (!name || !slug) {
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
- }
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
const dbErr = requireDb(emdash?.db);
if (dbErr) return dbErr;
@@ -86,8 +83,11 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
const denied = requirePerm(user, "taxonomies:manage");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
- const result = await handleTermDelete(emdash.db, name, slug);
+ const result = await handleTermDelete(emdash.db, name, slug, { locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to delete term", "TERM_DELETE_ERROR");
diff --git a/packages/core/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts b/packages/core/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts
new file mode 100644
index 000000000..be1176a28
--- /dev/null
+++ b/packages/core/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts
@@ -0,0 +1,89 @@
+/**
+ * Term translation endpoints
+ *
+ * GET /_emdash/api/taxonomies/:name/terms/:slug/translations[?locale=xx]
+ * POST /_emdash/api/taxonomies/:name/terms/:slug/translations
+ * body: { locale, label?, slug? }
+ */
+
+import type { APIRoute } from "astro";
+import { z } from "zod";
+
+import { requirePerm } from "#api/authorize.js";
+import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
+import {
+ handleTermCreate,
+ handleTermGet,
+ handleTermTranslations,
+} from "#api/handlers/taxonomies.js";
+import { isParseError, parseBody, parseQuery } from "#api/parse.js";
+import { localeFilterQuery } from "#api/schemas.js";
+
+export const prerender = false;
+
+const createTermTranslationBody = z
+ .object({
+ locale: z.string().min(1),
+ label: z.string().min(1).optional(),
+ slug: z.string().min(1).optional(),
+ })
+ .meta({ id: "CreateTermTranslationBody" });
+
+export const GET: APIRoute = async ({ params, request, locals }) => {
+ const { emdash, user } = locals;
+ const { name, slug } = params;
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
+
+ const dbErr = requireDb(emdash?.db);
+ if (dbErr) return dbErr;
+
+ const denied = requirePerm(user, "taxonomies:read");
+ if (denied) return denied;
+
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
+ try {
+ const anchor = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
+ if (!anchor.success) return unwrapResult(anchor);
+ const result = await handleTermTranslations(emdash.db, anchor.data.term.id);
+ return unwrapResult(result);
+ } catch (error) {
+ return handleError(error, "Failed to list term translations", "TERM_TRANSLATIONS_ERROR");
+ }
+};
+
+export const POST: APIRoute = async ({ params, request, locals }) => {
+ const { emdash, user } = locals;
+ const { name, slug } = params;
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
+
+ const dbErr = requireDb(emdash?.db);
+ if (dbErr) return dbErr;
+
+ const denied = requirePerm(user, "taxonomies:manage");
+ if (denied) return denied;
+
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
+ try {
+ const body = await parseBody(request, createTermTranslationBody);
+ if (isParseError(body)) return body;
+
+ const source = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
+ if (!source.success) return unwrapResult(source);
+
+ const result = await handleTermCreate(emdash.db, name, {
+ slug: body.slug ?? source.data.term.slug,
+ label: body.label ?? source.data.term.label,
+ parentId: source.data.term.parentId,
+ description: source.data.term.description,
+ locale: body.locale,
+ translationOf: source.data.term.id,
+ });
+ return unwrapResult(result, 201);
+ } catch (error) {
+ return handleError(error, "Failed to create term translation", "TERM_TRANSLATION_CREATE_ERROR");
+ }
+};
diff --git a/packages/core/src/astro/routes/api/taxonomies/[name]/terms/index.ts b/packages/core/src/astro/routes/api/taxonomies/[name]/terms/index.ts
index 8cd078236..26f3940a6 100644
--- a/packages/core/src/astro/routes/api/taxonomies/[name]/terms/index.ts
+++ b/packages/core/src/astro/routes/api/taxonomies/[name]/terms/index.ts
@@ -1,8 +1,8 @@
/**
* Taxonomy terms list and create endpoint
*
- * GET /_emdash/api/taxonomies/:name/terms - List all terms (tree for hierarchical)
- * POST /_emdash/api/taxonomies/:name/terms - Create a new term
+ * GET /_emdash/api/taxonomies/:name/terms[?locale=xx] - List terms (tree for hierarchical)
+ * POST /_emdash/api/taxonomies/:name/terms - Create a new term (body may include locale & translationOf)
*/
import type { APIRoute } from "astro";
@@ -10,21 +10,18 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
import { handleTermCreate, handleTermList } from "#api/handlers/taxonomies.js";
-import { isParseError, parseBody } from "#api/parse.js";
-import { createTermBody } from "#api/schemas.js";
+import { isParseError, parseBody, parseQuery } from "#api/parse.js";
+import { createTermBody, localeFilterQuery } from "#api/schemas.js";
export const prerender = false;
/**
* List all terms for a taxonomy
*/
-export const GET: APIRoute = async ({ params, locals }) => {
+export const GET: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const { name } = params;
-
- if (!name) {
- return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
- }
+ if (!name) return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
const dbErr = requireDb(emdash?.db);
if (dbErr) return dbErr;
@@ -32,8 +29,11 @@ export const GET: APIRoute = async ({ params, locals }) => {
const denied = requirePerm(user, "taxonomies:read");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
- const result = await handleTermList(emdash.db, name);
+ const result = await handleTermList(emdash.db, name, { locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to list terms", "TERM_LIST_ERROR");
@@ -46,10 +46,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
export const POST: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const { name } = params;
-
- if (!name) {
- return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
- }
+ if (!name) return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
const dbErr = requireDb(emdash?.db);
if (dbErr) return dbErr;
diff --git a/packages/core/src/astro/routes/api/taxonomies/index.ts b/packages/core/src/astro/routes/api/taxonomies/index.ts
index 716d1dde9..d7996e692 100644
--- a/packages/core/src/astro/routes/api/taxonomies/index.ts
+++ b/packages/core/src/astro/routes/api/taxonomies/index.ts
@@ -1,8 +1,8 @@
/**
* Taxonomy definitions endpoint
*
- * GET /_emdash/api/taxonomies - List all taxonomy definitions
- * POST /_emdash/api/taxonomies - Create a custom taxonomy definition
+ * GET /_emdash/api/taxonomies[?locale=xx] - List taxonomy definitions
+ * POST /_emdash/api/taxonomies - Create a custom taxonomy definition
*/
import type { APIRoute } from "astro";
@@ -10,15 +10,15 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { handleError, requireDb, unwrapResult } from "#api/error.js";
import { handleTaxonomyCreate, handleTaxonomyList } from "#api/handlers/taxonomies.js";
-import { isParseError, parseBody } from "#api/parse.js";
-import { createTaxonomyDefBody } from "#api/schemas.js";
+import { isParseError, parseBody, parseQuery } from "#api/parse.js";
+import { createTaxonomyDefBody, localeFilterQuery } from "#api/schemas.js";
export const prerender = false;
/**
* List taxonomy definitions
*/
-export const GET: APIRoute = async ({ locals }) => {
+export const GET: APIRoute = async ({ request, locals }) => {
const { emdash, user } = locals;
const dbErr = requireDb(emdash?.db);
@@ -27,8 +27,11 @@ export const GET: APIRoute = async ({ locals }) => {
const denied = requirePerm(user, "taxonomies:read");
if (denied) return denied;
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
+ if (isParseError(query)) return query;
+
try {
- const result = await handleTaxonomyList(emdash.db);
+ const result = await handleTaxonomyList(emdash.db, { locale: query.locale });
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to list taxonomies", "TAXONOMY_LIST_ERROR");
diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts
index 6e45e61cd..ee94f65b0 100644
--- a/packages/core/src/cli/commands/export-seed.ts
+++ b/packages/core/src/cli/commands/export-seed.ts
@@ -212,41 +212,69 @@ async function exportCollections(db: Kysely): Promise): Promise {
- // Get taxonomy definitions
- const defs = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
+ const i18nEnabled = isI18nEnabled();
+
+ // Mirrors the content export pattern: one entry per (name, locale), stable
+ // seed-local id, translations linked via `translationOf` to the anchor's id.
+ const defs = await db
+ .selectFrom("_emdash_taxonomy_defs")
+ .selectAll()
+ .orderBy(["name", "locale"])
+ .execute();
const result: SeedTaxonomy[] = [];
const termRepo = new TaxonomyRepository(db);
+ // translation_group -> seed-local id of first def we emitted in that group.
+ const defGroupToSeedId = new Map();
+
for (const def of defs) {
- // Get terms for this taxonomy
- const terms = await termRepo.findByName(def.name);
+ const defSeedId =
+ i18nEnabled && def.locale ? `tax:${def.name}:${def.locale}` : `tax:${def.name}`;
- // Build term tree for hierarchical taxonomies
- const seedTerms: SeedTaxonomyTerm[] = [];
+ // Terms in this def's locale.
+ const terms = await termRepo.findByName(def.name, { locale: def.locale });
- // First, create a map of id -> slug for parent resolution
+ // id -> slug for parent resolution within this locale.
const idToSlug = new Map();
- for (const term of terms) {
- idToSlug.set(term.id, term.slug);
- }
+ for (const term of terms) idToSlug.set(term.id, term.slug);
+
+ // translation_group -> seed id of the anchor term.
+ const termGroupToSeedId = new Map();
+ const seedTerms: SeedTaxonomyTerm[] = [];
for (const term of terms) {
+ const termSeedId =
+ i18nEnabled && term.locale
+ ? `term:${def.name}:${term.slug}:${term.locale}`
+ : `term:${def.name}:${term.slug}`;
+
const seedTerm: SeedTaxonomyTerm = {
+ id: termSeedId,
slug: term.slug,
label: term.label,
description: typeof term.data?.description === "string" ? term.data.description : undefined,
};
- // Resolve parent slug
- if (term.parentId) {
- seedTerm.parent = idToSlug.get(term.parentId);
+ if (term.parentId) seedTerm.parent = idToSlug.get(term.parentId);
+
+ if (i18nEnabled && term.locale) {
+ seedTerm.locale = term.locale;
+ if (term.translationGroup) {
+ const anchor = termGroupToSeedId.get(term.translationGroup);
+ if (anchor) seedTerm.translationOf = anchor;
+ else termGroupToSeedId.set(term.translationGroup, termSeedId);
+ }
}
seedTerms.push(seedTerm);
}
+ // Anchors first so import can resolve `translationOf`.
+ seedTerms.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf));
+
const taxonomy: SeedTaxonomy = {
+ id: defSeedId,
name: def.name,
label: def.label,
labelSingular: def.label_singular || undefined,
@@ -254,13 +282,23 @@ async function exportTaxonomies(db: Kysely): Promise {
collections: def.collections ? JSON.parse(def.collections) : [],
};
- if (seedTerms.length > 0) {
- taxonomy.terms = seedTerms;
+ if (i18nEnabled && def.locale) {
+ taxonomy.locale = def.locale;
+ if (def.translation_group) {
+ const anchor = defGroupToSeedId.get(def.translation_group);
+ if (anchor) taxonomy.translationOf = anchor;
+ else defGroupToSeedId.set(def.translation_group, defSeedId);
+ }
}
+ if (seedTerms.length > 0) taxonomy.terms = seedTerms;
+
result.push(taxonomy);
}
+ // Anchors first at def level too.
+ result.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf));
+
return result;
}
@@ -268,13 +306,22 @@ async function exportTaxonomies(db: Kysely): Promise {
* Export menus with their items
*/
async function exportMenus(db: Kysely): Promise {
- // Get all menus
- const menus = await db.selectFrom("_emdash_menus").selectAll().execute();
+ const i18nEnabled = isI18nEnabled();
+
+ const menus = await db
+ .selectFrom("_emdash_menus")
+ .selectAll()
+ .orderBy(["name", "locale"])
+ .execute();
const result: SeedMenu[] = [];
+ // translation_group -> seed-local id of the anchor menu in that group.
+ const groupToSeedId = new Map();
for (const menu of menus) {
- // Get menu items
+ const seedId =
+ i18nEnabled && menu.locale ? `menu:${menu.name}:${menu.locale}` : `menu:${menu.name}`;
+
const items = await db
.selectFrom("_emdash_menu_items")
.selectAll()
@@ -282,16 +329,30 @@ async function exportMenus(db: Kysely): Promise {
.orderBy("sort_order", "asc")
.execute();
- // Build item tree
const seedItems = buildMenuItemTree(items);
- result.push({
+ const seedMenu: SeedMenu = {
+ id: seedId,
name: menu.name,
label: menu.label,
items: seedItems,
- });
+ };
+
+ if (i18nEnabled && menu.locale) {
+ seedMenu.locale = menu.locale;
+ if (menu.translation_group) {
+ const anchor = groupToSeedId.get(menu.translation_group);
+ if (anchor) seedMenu.translationOf = anchor;
+ else groupToSeedId.set(menu.translation_group, seedId);
+ }
+ }
+
+ result.push(seedMenu);
}
+ // Anchors first so import can resolve `translationOf`.
+ result.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf));
+
return result;
}
diff --git a/packages/core/src/database/migrations/036_i18n_menus_and_taxonomies.ts b/packages/core/src/database/migrations/036_i18n_menus_and_taxonomies.ts
new file mode 100644
index 000000000..a5053c691
--- /dev/null
+++ b/packages/core/src/database/migrations/036_i18n_menus_and_taxonomies.ts
@@ -0,0 +1,477 @@
+import type { Kysely } from "kysely";
+import { sql } from "kysely";
+
+import { getI18nConfig } from "../../i18n/config.js";
+import { currentTimestamp, isSqlite } from "../dialect-helpers.js";
+import { validateIdentifier } from "../validate.js";
+
+/**
+ * i18n for menus + taxonomies. Adds `locale` + `translation_group` to system
+ * tables and stores translation_groups (not row ids) in
+ * `_emdash_menu_items.reference_id` and `content_taxonomies.taxonomy_id`.
+ * Backfill locale and column DEFAULTs use the site's configured defaultLocale.
+ */
+
+function getDefaultLocale(): string {
+ return getI18nConfig()?.defaultLocale ?? "en";
+}
+
+export async function up(db: Kysely): Promise {
+ const defaultLocale = getDefaultLocale();
+
+ if (isSqlite(db)) {
+ // FKs off: rebuilding `taxonomies` would CASCADE-wipe `content_taxonomies`.
+ await sql.raw(`PRAGMA foreign_keys = OFF`).execute(db);
+ try {
+ await rebuildMenus(db, defaultLocale);
+ await addItemColumns(db, defaultLocale);
+ await rebuildTaxonomies(db, defaultLocale);
+ await rebuildTaxonomyDefs(db, defaultLocale);
+ await rebuildContentTaxonomies(db);
+ await remapMenuItemRefs(db);
+ } finally {
+ await sql.raw(`PRAGMA foreign_keys = ON`).execute(db);
+ }
+ return;
+ }
+
+ await pgWiden(db, "_emdash_menus", ["name"], ["name", "locale"], defaultLocale);
+ await pgWiden(db, "_emdash_menu_items", null, null, defaultLocale);
+ await pgWiden(db, "taxonomies", ["name", "slug"], ["name", "slug", "locale"], defaultLocale);
+ await pgWiden(db, "_emdash_taxonomy_defs", ["name"], ["name", "locale"], defaultLocale);
+ await pgRemapContentTaxonomies(db);
+ await remapMenuItemRefs(db);
+}
+
+async function rebuildMenus(db: Kysely, defaultLocale: string): Promise {
+ if (await hasColumn(db, "_emdash_menus", "locale")) return;
+ await sql.raw(`DROP TABLE IF EXISTS "_emdash_menus_new"`).execute(db);
+
+ await db.schema
+ .createTable("_emdash_menus_new")
+ .addColumn("id", "text", (c) => c.primaryKey())
+ .addColumn("name", "text", (c) => c.notNull())
+ .addColumn("label", "text", (c) => c.notNull())
+ .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
+ .addColumn("updated_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
+ .addColumn("locale", "text", (c) => c.notNull().defaultTo(defaultLocale))
+ .addColumn("translation_group", "text")
+ .addUniqueConstraint("_emdash_menus_name_locale_unique", ["name", "locale"])
+ .execute();
+
+ await sql`
+ INSERT INTO _emdash_menus_new (id, name, label, created_at, updated_at, locale, translation_group)
+ SELECT id, name, label, created_at, updated_at, ${defaultLocale}, id FROM _emdash_menus
+ `.execute(db);
+
+ await db.schema.dropTable("_emdash_menus").execute();
+ await sql`ALTER TABLE _emdash_menus_new RENAME TO _emdash_menus`.execute(db);
+
+ await db.schema
+ .createIndex("idx__emdash_menus_locale")
+ .on("_emdash_menus")
+ .column("locale")
+ .execute();
+ await db.schema
+ .createIndex("idx__emdash_menus_translation_group")
+ .on("_emdash_menus")
+ .column("translation_group")
+ .execute();
+}
+
+async function addItemColumns(db: Kysely, defaultLocale: string): Promise {
+ if (await hasColumn(db, "_emdash_menu_items", "locale")) return;
+
+ await db.schema
+ .alterTable("_emdash_menu_items")
+ .addColumn("locale", "text", (c) => c.notNull().defaultTo(defaultLocale))
+ .execute();
+ await db.schema.alterTable("_emdash_menu_items").addColumn("translation_group", "text").execute();
+
+ await sql`UPDATE _emdash_menu_items SET translation_group = id`.execute(db);
+
+ await db.schema
+ .createIndex("idx__emdash_menu_items_locale")
+ .on("_emdash_menu_items")
+ .column("locale")
+ .execute();
+ await db.schema
+ .createIndex("idx__emdash_menu_items_translation_group")
+ .on("_emdash_menu_items")
+ .column("translation_group")
+ .execute();
+}
+
+async function rebuildTaxonomies(db: Kysely, defaultLocale: string): Promise {
+ if (await hasColumn(db, "taxonomies", "locale")) return;
+ await sql.raw(`DROP TABLE IF EXISTS "taxonomies_new"`).execute(db);
+ await sql`DROP INDEX IF EXISTS idx_taxonomies_name`.execute(db);
+
+ await db.schema
+ .createTable("taxonomies_new")
+ .addColumn("id", "text", (c) => c.primaryKey())
+ .addColumn("name", "text", (c) => c.notNull())
+ .addColumn("slug", "text", (c) => c.notNull())
+ .addColumn("label", "text", (c) => c.notNull())
+ .addColumn("parent_id", "text")
+ .addColumn("data", "text")
+ .addColumn("locale", "text", (c) => c.notNull().defaultTo(defaultLocale))
+ .addColumn("translation_group", "text")
+ .addUniqueConstraint("taxonomies_name_slug_locale_unique", ["name", "slug", "locale"])
+ .addForeignKeyConstraint("taxonomies_parent_fk", ["parent_id"], "taxonomies", ["id"], (cb) =>
+ cb.onDelete("set null"),
+ )
+ .execute();
+
+ await sql`
+ INSERT INTO taxonomies_new (id, name, slug, label, parent_id, data, locale, translation_group)
+ SELECT id, name, slug, label, parent_id, data, ${defaultLocale}, id FROM taxonomies
+ `.execute(db);
+
+ await db.schema.dropTable("taxonomies").execute();
+ await sql`ALTER TABLE taxonomies_new RENAME TO taxonomies`.execute(db);
+
+ await db.schema.createIndex("idx_taxonomies_name").on("taxonomies").column("name").execute();
+ await db.schema.createIndex("idx_taxonomies_locale").on("taxonomies").column("locale").execute();
+ await db.schema
+ .createIndex("idx_taxonomies_translation_group")
+ .on("taxonomies")
+ .column("translation_group")
+ .execute();
+}
+
+async function rebuildTaxonomyDefs(db: Kysely, defaultLocale: string): Promise {
+ if (await hasColumn(db, "_emdash_taxonomy_defs", "locale")) return;
+ await sql.raw(`DROP TABLE IF EXISTS "_emdash_taxonomy_defs_new"`).execute(db);
+
+ await db.schema
+ .createTable("_emdash_taxonomy_defs_new")
+ .addColumn("id", "text", (c) => c.primaryKey())
+ .addColumn("name", "text", (c) => c.notNull())
+ .addColumn("label", "text", (c) => c.notNull())
+ .addColumn("label_singular", "text")
+ .addColumn("hierarchical", "integer", (c) => c.defaultTo(0))
+ .addColumn("collections", "text")
+ .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
+ .addColumn("locale", "text", (c) => c.notNull().defaultTo(defaultLocale))
+ .addColumn("translation_group", "text")
+ .addUniqueConstraint("_emdash_taxonomy_defs_name_locale_unique", ["name", "locale"])
+ .execute();
+
+ await sql`
+ INSERT INTO _emdash_taxonomy_defs_new
+ (id, name, label, label_singular, hierarchical, collections, created_at, locale, translation_group)
+ SELECT id, name, label, label_singular, hierarchical, collections, created_at, ${defaultLocale}, id
+ FROM _emdash_taxonomy_defs
+ `.execute(db);
+
+ await db.schema.dropTable("_emdash_taxonomy_defs").execute();
+ await sql`ALTER TABLE _emdash_taxonomy_defs_new RENAME TO _emdash_taxonomy_defs`.execute(db);
+
+ await db.schema
+ .createIndex("idx__emdash_taxonomy_defs_locale")
+ .on("_emdash_taxonomy_defs")
+ .column("locale")
+ .execute();
+ await db.schema
+ .createIndex("idx__emdash_taxonomy_defs_translation_group")
+ .on("_emdash_taxonomy_defs")
+ .column("translation_group")
+ .execute();
+}
+
+async function rebuildContentTaxonomies(db: Kysely): Promise {
+ // Drop the FK (taxonomy_id now points at translation_group, not a row id)
+ // and remap the values.
+ const fks = await sql<{ id: number }>`PRAGMA foreign_key_list(content_taxonomies)`.execute(db);
+ if (fks.rows.length === 0) return;
+
+ await sql.raw(`DROP TABLE IF EXISTS "content_taxonomies_new"`).execute(db);
+ await db.schema
+ .createTable("content_taxonomies_new")
+ .addColumn("collection", "text", (c) => c.notNull())
+ .addColumn("entry_id", "text", (c) => c.notNull())
+ .addColumn("taxonomy_id", "text", (c) => c.notNull())
+ .addPrimaryKeyConstraint("content_taxonomies_pk", ["collection", "entry_id", "taxonomy_id"])
+ .execute();
+
+ await sql`
+ INSERT OR IGNORE INTO content_taxonomies_new (collection, entry_id, taxonomy_id)
+ SELECT ct.collection, ct.entry_id, COALESCE(
+ (SELECT t.translation_group FROM taxonomies t WHERE t.id = ct.taxonomy_id),
+ ct.taxonomy_id
+ )
+ FROM content_taxonomies ct
+ `.execute(db);
+
+ await db.schema.dropTable("content_taxonomies").execute();
+ await sql`ALTER TABLE content_taxonomies_new RENAME TO content_taxonomies`.execute(db);
+}
+
+async function remapMenuItemRefs(db: Kysely): Promise {
+ // Items with `reference_collection IS NULL` are left untouched — the
+ // runtime fallback in `menus/index.ts` resolves them by id.
+ const collections = await sql<{ slug: string }>`SELECT slug FROM _emdash_collections`.execute(db);
+ for (const { slug } of collections.rows) {
+ validateIdentifier(slug, "collection slug");
+ const ec = sql.ref(`ec_${slug}`);
+ await sql`
+ UPDATE _emdash_menu_items SET reference_id = (
+ SELECT translation_group FROM ${ec} WHERE ${ec}.id = _emdash_menu_items.reference_id
+ )
+ WHERE reference_collection = ${slug} AND reference_id IS NOT NULL
+ AND EXISTS (SELECT 1 FROM ${ec} WHERE ${ec}.id = _emdash_menu_items.reference_id)
+ `.execute(db);
+ }
+ await sql`
+ UPDATE _emdash_menu_items SET reference_id = (
+ SELECT translation_group FROM taxonomies WHERE taxonomies.id = _emdash_menu_items.reference_id
+ )
+ WHERE type = 'taxonomy' AND reference_id IS NOT NULL
+ AND EXISTS (SELECT 1 FROM taxonomies WHERE taxonomies.id = _emdash_menu_items.reference_id)
+ `.execute(db);
+}
+
+async function pgWiden(
+ db: Kysely,
+ table: string,
+ oldCols: string[] | null,
+ newCols: string[] | null,
+ defaultLocale: string,
+): Promise {
+ validateSystemIdent(table);
+ const ref = sql.ref(table);
+ await sql`ALTER TABLE ${ref} ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT ${sql.lit(defaultLocale)}`.execute(
+ db,
+ );
+ await sql`ALTER TABLE ${ref} ADD COLUMN IF NOT EXISTS translation_group TEXT`.execute(db);
+ await sql`UPDATE ${ref} SET translation_group = id WHERE translation_group IS NULL`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table}_locale`)} ON ${ref} (locale)`.execute(
+ db,
+ );
+ await sql`
+ CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table}_translation_group`)} ON ${ref} (translation_group)
+ `.execute(db);
+
+ if (!oldCols || !newCols) return;
+ for (const c of [...oldCols, ...newCols]) validateSystemIdent(c);
+ const cons = await sql<{ conname: string }>`
+ SELECT conname FROM pg_constraint c
+ WHERE c.conrelid = ${table}::regclass AND c.contype = 'u'
+ AND array_length(c.conkey, 1) = ${oldCols.length}
+ AND (
+ SELECT array_agg(a.attname ORDER BY pos.ord)
+ FROM unnest(c.conkey) WITH ORDINALITY AS pos(attnum, ord)
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = pos.attnum
+ )::text[] = ${oldCols}::text[]
+ `.execute(db);
+ for (const c of cons.rows) {
+ await sql`ALTER TABLE ${ref} DROP CONSTRAINT ${sql.ref(c.conname)}`.execute(db);
+ }
+ const cols = sql.join(
+ newCols.map((c) => sql.ref(c)),
+ sql`, `,
+ );
+ await sql`
+ ALTER TABLE ${ref}
+ ADD CONSTRAINT ${sql.ref(`${table}_${newCols.join("_")}_unique`)} UNIQUE (${cols})
+ `.execute(db);
+}
+
+async function pgRemapContentTaxonomies(db: Kysely): Promise {
+ const fks = await sql<{ conname: string }>`
+ SELECT conname FROM pg_constraint
+ WHERE conrelid = 'content_taxonomies'::regclass AND contype = 'f'
+ `.execute(db);
+ for (const c of fks.rows) {
+ await sql`ALTER TABLE content_taxonomies DROP CONSTRAINT ${sql.ref(c.conname)}`.execute(db);
+ }
+ await sql`
+ UPDATE content_taxonomies SET taxonomy_id = t.translation_group
+ FROM taxonomies t WHERE t.id = content_taxonomies.taxonomy_id
+ `.execute(db);
+}
+
+async function hasColumn(db: Kysely, table: string, column: string): Promise {
+ const rows = await sql<{ name: string }>`PRAGMA table_info(${sql.ref(table)})`.execute(db);
+ return rows.rows.some((r) => r.name === column);
+}
+
+const SYSTEM_IDENT = /^[_a-z][a-z0-9_]*$/;
+function validateSystemIdent(name: string): void {
+ if (!SYSTEM_IDENT.test(name)) throw new Error(`Invalid identifier: "${name}"`);
+}
+
+/**
+ * down() is destructive on multi-locale installs (dropping `locale` collapses
+ * translated rows onto an ambiguous unique key). Refuse to run when any row
+ * sits at a locale other than the configured defaultLocale.
+ */
+async function assertSingleLocale(db: Kysely, defaultLocale: string): Promise {
+ const tables = ["_emdash_menus", "_emdash_menu_items", "taxonomies", "_emdash_taxonomy_defs"];
+ for (const table of tables) {
+ validateSystemIdent(table);
+ const result = await sql<{ count: number | string }>`
+ SELECT COUNT(*) AS count FROM ${sql.ref(table)} WHERE locale != ${defaultLocale}
+ `.execute(db);
+ const count = Number(result.rows[0]?.count ?? 0);
+ if (count > 0) {
+ throw new Error(
+ `Cannot revert migration 036_i18n_menus_and_taxonomies: ` +
+ `${count} row(s) in "${table}" use a non-default locale ` +
+ `(defaultLocale="${defaultLocale}"). ` +
+ `Reverting would drop them silently. Export translations first ` +
+ `(or delete them) and re-run the rollback. ` +
+ `See packages/core/src/database/migrations/036_i18n_menus_and_taxonomies.ts.`,
+ );
+ }
+ }
+}
+
+export async function down(db: Kysely): Promise {
+ const defaultLocale = getDefaultLocale();
+ await assertSingleLocale(db, defaultLocale);
+
+ const widenedTables = [
+ "_emdash_menus",
+ "_emdash_menu_items",
+ "taxonomies",
+ "_emdash_taxonomy_defs",
+ ];
+
+ if (isSqlite(db)) {
+ // FKs off — same reason as up().
+ await sql.raw(`PRAGMA foreign_keys = OFF`).execute(db);
+ try {
+ // Indexes first: a locale index blocks DROP COLUMN on _emdash_menu_items.
+ for (const t of widenedTables) {
+ await sql.raw(`DROP INDEX IF EXISTS idx_${t}_locale`).execute(db);
+ await sql.raw(`DROP INDEX IF EXISTS idx_${t}_translation_group`).execute(db);
+ }
+
+ await rebuildContentTaxonomiesDown(db, defaultLocale);
+ await rebuildMenusDown(db);
+ await rebuildMenuItemsDown(db);
+ await rebuildTaxonomiesDown(db);
+ await rebuildTaxonomyDefsDown(db);
+ } finally {
+ await sql.raw(`PRAGMA foreign_keys = ON`).execute(db);
+ }
+ return;
+ }
+
+ for (const t of widenedTables) {
+ await sql.raw(`DROP INDEX IF EXISTS idx_${t}_locale`).execute(db);
+ await sql.raw(`DROP INDEX IF EXISTS idx_${t}_translation_group`).execute(db);
+ await sql.raw(`ALTER TABLE "${t}" DROP COLUMN IF EXISTS locale`).execute(db);
+ await sql.raw(`ALTER TABLE "${t}" DROP COLUMN IF EXISTS translation_group`).execute(db);
+ }
+}
+
+async function rebuildContentTaxonomiesDown(
+ db: Kysely,
+ defaultLocale: string,
+): Promise {
+ await sql.raw(`DROP TABLE IF EXISTS "content_taxonomies_new"`).execute(db);
+ await db.schema
+ .createTable("content_taxonomies_new")
+ .addColumn("collection", "text", (c) => c.notNull())
+ .addColumn("entry_id", "text", (c) => c.notNull())
+ .addColumn("taxonomy_id", "text", (c) => c.notNull())
+ .addPrimaryKeyConstraint("content_taxonomies_pk", ["collection", "entry_id", "taxonomy_id"])
+ .addForeignKeyConstraint(
+ "content_taxonomies_taxonomy_fk",
+ ["taxonomy_id"],
+ "taxonomies",
+ ["id"],
+ (cb) => cb.onDelete("cascade"),
+ )
+ .execute();
+
+ // Map translation_group back to a row id (assertSingleLocale guarantees a 1:1 match).
+ await sql`
+ INSERT OR IGNORE INTO content_taxonomies_new (collection, entry_id, taxonomy_id)
+ SELECT ct.collection, ct.entry_id, COALESCE(
+ (SELECT t.id FROM taxonomies t WHERE t.translation_group = ct.taxonomy_id AND t.locale = ${defaultLocale}),
+ ct.taxonomy_id
+ )
+ FROM content_taxonomies ct
+ `.execute(db);
+
+ await db.schema.dropTable("content_taxonomies").execute();
+ await sql`ALTER TABLE content_taxonomies_new RENAME TO content_taxonomies`.execute(db);
+}
+
+async function rebuildMenusDown(db: Kysely): Promise {
+ await sql.raw(`DROP TABLE IF EXISTS "_emdash_menus_old"`).execute(db);
+ await db.schema
+ .createTable("_emdash_menus_old")
+ .addColumn("id", "text", (c) => c.primaryKey())
+ .addColumn("name", "text", (c) => c.notNull().unique())
+ .addColumn("label", "text", (c) => c.notNull())
+ .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
+ .addColumn("updated_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
+ .execute();
+ await sql`
+ INSERT INTO _emdash_menus_old (id, name, label, created_at, updated_at)
+ SELECT id, name, label, created_at, updated_at FROM _emdash_menus
+ `.execute(db);
+ await db.schema.dropTable("_emdash_menus").execute();
+ await sql`ALTER TABLE _emdash_menus_old RENAME TO _emdash_menus`.execute(db);
+}
+
+async function rebuildMenuItemsDown(db: Kysely): Promise {
+ // No UNIQUE on (locale,…) here, so DROP COLUMN is enough.
+ await sql.raw(`ALTER TABLE _emdash_menu_items DROP COLUMN locale`).execute(db);
+ await sql.raw(`ALTER TABLE _emdash_menu_items DROP COLUMN translation_group`).execute(db);
+}
+
+async function rebuildTaxonomiesDown(db: Kysely): Promise {
+ await sql.raw(`DROP TABLE IF EXISTS "taxonomies_old"`).execute(db);
+ await db.schema
+ .createTable("taxonomies_old")
+ .addColumn("id", "text", (c) => c.primaryKey())
+ .addColumn("name", "text", (c) => c.notNull())
+ .addColumn("slug", "text", (c) => c.notNull())
+ .addColumn("label", "text", (c) => c.notNull())
+ .addColumn("parent_id", "text")
+ .addColumn("data", "text")
+ .addUniqueConstraint("taxonomies_name_slug_unique", ["name", "slug"])
+ .addForeignKeyConstraint(
+ "taxonomies_parent_fk",
+ ["parent_id"],
+ "taxonomies_old",
+ ["id"],
+ (cb) => cb.onDelete("set null"),
+ )
+ .execute();
+ await sql`
+ INSERT INTO taxonomies_old (id, name, slug, label, parent_id, data)
+ SELECT id, name, slug, label, parent_id, data FROM taxonomies
+ `.execute(db);
+ await db.schema.dropTable("taxonomies").execute();
+ await sql`ALTER TABLE taxonomies_old RENAME TO taxonomies`.execute(db);
+ await db.schema.createIndex("idx_taxonomies_name").on("taxonomies").column("name").execute();
+}
+
+async function rebuildTaxonomyDefsDown(db: Kysely): Promise {
+ await sql.raw(`DROP TABLE IF EXISTS "_emdash_taxonomy_defs_old"`).execute(db);
+ await db.schema
+ .createTable("_emdash_taxonomy_defs_old")
+ .addColumn("id", "text", (c) => c.primaryKey())
+ .addColumn("name", "text", (c) => c.notNull().unique())
+ .addColumn("label", "text", (c) => c.notNull())
+ .addColumn("label_singular", "text")
+ .addColumn("hierarchical", "integer", (c) => c.defaultTo(0))
+ .addColumn("collections", "text")
+ .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
+ .execute();
+ await sql`
+ INSERT INTO _emdash_taxonomy_defs_old
+ (id, name, label, label_singular, hierarchical, collections, created_at)
+ SELECT id, name, label, label_singular, hierarchical, collections, created_at
+ FROM _emdash_taxonomy_defs
+ `.execute(db);
+ await db.schema.dropTable("_emdash_taxonomy_defs").execute();
+ await sql`ALTER TABLE _emdash_taxonomy_defs_old RENAME TO _emdash_taxonomy_defs`.execute(db);
+}
diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts
index 84aa8f70e..9f8e0e108 100644
--- a/packages/core/src/database/migrations/runner.ts
+++ b/packages/core/src/database/migrations/runner.ts
@@ -36,6 +36,7 @@ import * as m032 from "./032_rate_limits.js";
import * as m033 from "./033_optimize_content_indexes.js";
import * as m034 from "./034_published_at_index.js";
import * as m035 from "./035_bounded_404_log.js";
+import * as m036 from "./036_i18n_menus_and_taxonomies.js";
const MIGRATIONS: Readonly> = Object.freeze({
"001_initial": m001,
@@ -72,6 +73,7 @@ const MIGRATIONS: Readonly> = Object.freeze({
"033_optimize_content_indexes": m033,
"034_published_at_index": m034,
"035_bounded_404_log": m035,
+ "036_i18n_menus_and_taxonomies": m036,
});
/** Total number of registered migrations. Exported for use in tests. */
diff --git a/packages/core/src/database/repositories/taxonomy.ts b/packages/core/src/database/repositories/taxonomy.ts
index 45876be76..c3d2b53f8 100644
--- a/packages/core/src/database/repositories/taxonomy.ts
+++ b/packages/core/src/database/repositories/taxonomy.ts
@@ -1,4 +1,4 @@
-import type { Kysely } from "kysely";
+import type { Kysely, Selectable } from "kysely";
import { ulid } from "ulidx";
import type { Database, TaxonomyTable, ContentTaxonomyTable } from "../types.js";
@@ -10,6 +10,8 @@ export interface Taxonomy {
label: string;
parentId: string | null;
data: Record | null;
+ locale: string;
+ translationGroup: string | null;
}
export interface CreateTaxonomyInput {
@@ -18,6 +20,11 @@ export interface CreateTaxonomyInput {
label: string;
parentId?: string;
data?: Record;
+ /** Omit to let the DB default (current value: 'en') apply. Higher layers
+ * resolve the locale from the request context / i18n config. */
+ locale?: string;
+ /** When set, links the new term into the source term's translation_group. */
+ translationOf?: string;
}
export interface UpdateTaxonomyInput {
@@ -27,16 +34,29 @@ export interface UpdateTaxonomyInput {
data?: Record;
}
+export interface FindOptions {
+ parentId?: string | null;
+ locale?: string;
+}
+
/**
- * Taxonomy repository for categories, tags, and other classification
+ * Taxonomy repository for categories, tags, and other classification.
*
- * Taxonomies are hierarchical (via parentId) and can be attached to content entries.
+ * Terms are per-locale. Translations of the same term share a `translation_group`
+ * ULID. `content_taxonomies.taxonomy_id` stores the translation_group so a single
+ * association spans every locale of a post.
+ *
+ * The repository does not resolve locale fallbacks on its own — callers supply
+ * the locale they want. Runtime helpers and handlers use `getFallbackChain()`
+ * from `i18n/config` when they need fallback behaviour.
*/
export class TaxonomyRepository {
constructor(private db: Kysely) {}
/**
- * Create a new taxonomy term
+ * Create a new taxonomy term. When `translationOf` is set the new row joins
+ * the source term's translation_group; otherwise a fresh group is minted
+ * (matching the migration backfill pattern `translation_group = id`).
*/
async create(input: CreateTaxonomyInput): Promise {
const id = ulid();
@@ -44,58 +64,68 @@ export class TaxonomyRepository {
// Empty-string parentId is coerced to null defensively. Higher layers
// also normalize this — see handleTermCreate / handleTermUpdate.
const parentId = input.parentId === undefined || input.parentId === "" ? null : input.parentId;
- const row: TaxonomyTable = {
- id,
- name: input.name,
- slug: input.slug,
- label: input.label,
- parent_id: parentId,
- data: input.data ? JSON.stringify(input.data) : null,
- };
- await this.db.insertInto("taxonomies").values(row).execute();
+ let translationGroup = id;
+ if (input.translationOf) {
+ const source = await this.findById(input.translationOf);
+ if (source?.translationGroup) translationGroup = source.translationGroup;
+ }
+
+ await this.db
+ .insertInto("taxonomies")
+ .values({
+ id,
+ name: input.name,
+ slug: input.slug,
+ label: input.label,
+ parent_id: parentId,
+ data: input.data ? JSON.stringify(input.data) : null,
+ // When omitted, the DB DEFAULT 'en' is used — keeps behaviour
+ // consistent with ContentRepository and lets higher layers
+ // supply an explicit locale from request context.
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
+ translation_group: translationGroup,
+ })
+ .execute();
const taxonomy = await this.findById(id);
- if (!taxonomy) {
- throw new Error("Failed to create taxonomy");
- }
+ if (!taxonomy) throw new Error("Failed to create taxonomy");
return taxonomy;
}
- /**
- * Find taxonomy by ID
- */
async findById(id: string): Promise {
const row = await this.db
.selectFrom("taxonomies")
.selectAll()
.where("id", "=", id)
.executeTakeFirst();
-
return row ? this.rowToTaxonomy(row) : null;
}
/**
- * Find taxonomy by name and slug (unique constraint)
+ * Find a term by (name, slug). When `locale` is provided, filter by it.
+ * When omitted, returns the lowest-locale-code match (deterministic across
+ * calls). Mirrors `ContentRepository.findBySlug`.
*/
- async findBySlug(name: string, slug: string): Promise {
- const row = await this.db
+ async findBySlug(name: string, slug: string, locale?: string): Promise {
+ let query = this.db
.selectFrom("taxonomies")
.selectAll()
.where("name", "=", name)
- .where("slug", "=", slug)
- .executeTakeFirst();
-
+ .where("slug", "=", slug);
+ if (locale !== undefined) query = query.where("locale", "=", locale);
+ const row = await query.orderBy("locale", "asc").executeTakeFirst();
return row ? this.rowToTaxonomy(row) : null;
}
/**
- * Get all terms for a taxonomy (e.g., all categories)
+ * Get all terms for a taxonomy (e.g., all categories).
+ *
+ * `id asc` is a stable tiebreaker for terms that share a label. Without it
+ * the SQL ordering is implementation-defined when labels match, which
+ * breaks keyset pagination over `(label, id)`.
*/
- async findByName(name: string, options: { parentId?: string | null } = {}): Promise {
- // `id asc` is a stable tiebreaker for terms that share a label.
- // Without it the SQL ordering is implementation-defined when labels
- // match, which breaks keyset pagination over `(label, id)`.
+ async findByName(name: string, options: FindOptions = {}): Promise {
let query = this.db
.selectFrom("taxonomies")
.selectAll()
@@ -103,6 +133,8 @@ export class TaxonomyRepository {
.orderBy("label", "asc")
.orderBy("id", "asc");
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
+
if (options.parentId !== undefined) {
if (options.parentId === null) {
query = query.where("parent_id", "is", null);
@@ -115,9 +147,6 @@ export class TaxonomyRepository {
return rows.map((row) => this.rowToTaxonomy(row));
}
- /**
- * Get children of a taxonomy term
- */
async findChildren(parentId: string): Promise {
const rows = await this.db
.selectFrom("taxonomies")
@@ -126,18 +155,28 @@ export class TaxonomyRepository {
.orderBy("label", "asc")
.orderBy("id", "asc")
.execute();
-
return rows.map((row) => this.rowToTaxonomy(row));
}
/**
- * Update a taxonomy term
+ * Every translation sibling of a term (including itself), identified by
+ * their shared `translation_group`.
*/
+ async findTranslations(translationGroup: string): Promise {
+ const rows = await this.db
+ .selectFrom("taxonomies")
+ .selectAll()
+ .where("translation_group", "=", translationGroup)
+ .orderBy("locale", "asc")
+ .execute();
+ return rows.map((row) => this.rowToTaxonomy(row));
+ }
+
async update(id: string, input: UpdateTaxonomyInput): Promise {
const existing = await this.findById(id);
if (!existing) return null;
- const updates: Partial = {};
+ const updates: Record = {};
if (input.slug !== undefined) updates.slug = input.slug;
if (input.label !== undefined) updates.label = input.label;
if (input.parentId !== undefined) {
@@ -153,31 +192,42 @@ export class TaxonomyRepository {
return this.findById(id);
}
- /**
- * Delete a taxonomy term
- */
async delete(id: string): Promise {
- // First remove any content associations
- await this.db.deleteFrom("content_taxonomies").where("taxonomy_id", "=", id).execute();
+ const term = await this.findById(id);
+ if (!term) return false;
+
+ // When deleting the last translation of a group the pivot rows that
+ // reference that translation_group become orphaned — purge them.
+ if (term.translationGroup) {
+ const siblings = await this.db
+ .selectFrom("taxonomies")
+ .select("id")
+ .where("translation_group", "=", term.translationGroup)
+ .where("id", "!=", id)
+ .execute();
+ if (siblings.length === 0) {
+ await this.db
+ .deleteFrom("content_taxonomies")
+ .where("taxonomy_id", "=", term.translationGroup)
+ .execute();
+ }
+ }
const result = await this.db.deleteFrom("taxonomies").where("id", "=", id).executeTakeFirst();
-
- return (result.numDeletedRows ?? 0) > 0;
+ return (result.numDeletedRows ?? 0n) > 0n;
}
- // --- Content-Taxonomy Junction ---
+ // --- Content-Taxonomy Junction (taxonomy_id stores the translation_group) ---
- /**
- * Attach a taxonomy term to a content entry
- */
async attachToEntry(collection: string, entryId: string, taxonomyId: string): Promise {
+ const group = await this.resolveTranslationGroup(taxonomyId);
+ if (!group) return;
+
const row: ContentTaxonomyTable = {
collection,
entry_id: entryId,
- taxonomy_id: taxonomyId,
+ taxonomy_id: group,
};
-
- // Use INSERT OR IGNORE pattern for idempotency
await this.db
.insertInto("content_taxonomies")
.values(row)
@@ -185,58 +235,72 @@ export class TaxonomyRepository {
.execute();
}
- /**
- * Detach a taxonomy term from a content entry
- */
async detachFromEntry(collection: string, entryId: string, taxonomyId: string): Promise {
+ const group = await this.resolveTranslationGroup(taxonomyId);
+ if (!group) return;
+
await this.db
.deleteFrom("content_taxonomies")
.where("collection", "=", collection)
.where("entry_id", "=", entryId)
- .where("taxonomy_id", "=", taxonomyId)
+ .where("taxonomy_id", "=", group)
.execute();
}
/**
- * Get all taxonomy terms for a content entry
+ * Taxonomy terms assigned to a content entry, resolved into a specific locale.
+ * Terms whose translation_group lacks a row in the requested locale are
+ * omitted — callers wanting fallback behaviour apply it themselves.
*/
async getTermsForEntry(
collection: string,
entryId: string,
taxonomyName?: string,
+ locale?: string,
): Promise {
let query = this.db
.selectFrom("content_taxonomies")
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
.selectAll("taxonomies")
.where("content_taxonomies.collection", "=", collection)
.where("content_taxonomies.entry_id", "=", entryId);
- if (taxonomyName) {
- query = query.where("taxonomies.name", "=", taxonomyName);
- }
+ if (taxonomyName) query = query.where("taxonomies.name", "=", taxonomyName);
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
- const rows = await query.execute();
+ const rows = await query.orderBy("taxonomies.locale", "asc").execute();
return rows.map((row) => this.rowToTaxonomy(row));
}
/**
- * Set all taxonomy terms for a content entry (replaces existing)
- * Uses batch operations to avoid N+1 queries.
+ * Replace all assignments of a given taxonomy for one content entry.
+ * Term ids OR translation_groups are accepted and normalised to groups.
*/
async setTermsForEntry(
collection: string,
entryId: string,
taxonomyName: string,
- taxonomyIds: string[],
+ termIds: string[],
): Promise {
- // Get current terms of this taxonomy type
- const current = await this.getTermsForEntry(collection, entryId, taxonomyName);
- const currentIds = new Set(current.map((t) => t.id));
- const newIds = new Set(taxonomyIds);
+ const groups: string[] = [];
+ for (const id of termIds) {
+ const group = await this.resolveTranslationGroup(id);
+ if (group) groups.push(group);
+ }
+ const newGroups = new Set(groups);
+
+ const current = await this.db
+ .selectFrom("content_taxonomies")
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
+ .select(["content_taxonomies.taxonomy_id as group"])
+ .distinct()
+ .where("content_taxonomies.collection", "=", collection)
+ .where("content_taxonomies.entry_id", "=", entryId)
+ .where("taxonomies.name", "=", taxonomyName)
+ .execute();
+ const currentGroups = new Set(current.map((r) => r.group));
- // Batch remove terms no longer present
- const toRemove = current.filter((t) => !newIds.has(t.id)).map((t) => t.id);
+ const toRemove = [...currentGroups].filter((g) => !newGroups.has(g));
if (toRemove.length > 0) {
await this.db
.deleteFrom("content_taxonomies")
@@ -246,8 +310,7 @@ export class TaxonomyRepository {
.execute();
}
- // Batch add new terms
- const toAdd = taxonomyIds.filter((id) => !currentIds.has(id));
+ const toAdd = [...newGroups].filter((g) => !currentGroups.has(g));
if (toAdd.length > 0) {
await this.db
.insertInto("content_taxonomies")
@@ -263,44 +326,86 @@ export class TaxonomyRepository {
}
}
- /**
- * Remove all taxonomy associations for an entry (use when entry is deleted)
- */
async clearEntryTerms(collection: string, entryId: string): Promise {
const result = await this.db
.deleteFrom("content_taxonomies")
.where("collection", "=", collection)
.where("entry_id", "=", entryId)
.executeTakeFirst();
-
return Number(result.numDeletedRows ?? 0);
}
/**
- * Count entries that have a specific taxonomy term
+ * Copy every term assignment from one content entry to another. Used when
+ * creating a translation of a post so the new translation inherits the
+ * source's term assignments. Safe to call when the source has no terms.
+ */
+ async copyEntryTerms(
+ collection: string,
+ sourceEntryId: string,
+ targetEntryId: string,
+ ): Promise {
+ const rows = await this.db
+ .selectFrom("content_taxonomies")
+ .select(["taxonomy_id"])
+ .where("collection", "=", collection)
+ .where("entry_id", "=", sourceEntryId)
+ .execute();
+ if (rows.length === 0) return;
+
+ await this.db
+ .insertInto("content_taxonomies")
+ .values(
+ rows.map((r) => ({
+ collection,
+ entry_id: targetEntryId,
+ taxonomy_id: r.taxonomy_id,
+ })),
+ )
+ .onConflict((oc) => oc.doNothing())
+ .execute();
+ }
+
+ /**
+ * Count content entries that use any translation of this term. Accepts
+ * either a term id or a translation_group — we normalise to the group.
*/
- async countEntriesWithTerm(taxonomyId: string): Promise {
+ async countEntriesWithTerm(termIdOrGroup: string): Promise {
+ const group = await this.resolveTranslationGroup(termIdOrGroup);
+ if (!group) return 0;
+
const result = await this.db
.selectFrom("content_taxonomies")
.select((eb) => eb.fn.count("entry_id").as("count"))
- .where("taxonomy_id", "=", taxonomyId)
+ .where("taxonomy_id", "=", group)
.executeTakeFirst();
+ return Number(result?.count ?? 0);
+ }
- return Number(result?.count || 0);
+ private async resolveTranslationGroup(idOrGroup: string): Promise {
+ const row = await this.db
+ .selectFrom("taxonomies")
+ .select(["translation_group"])
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
+ .executeTakeFirst();
+ return row?.translation_group ?? null;
}
/**
- * Batch count entries for multiple taxonomy term IDs.
+ * Batch count entries for multiple taxonomy translation_groups.
* Chunks the query at SQL_BATCH_SIZE to stay below D1's bind-parameter limit.
- * Returns a Map from term ID to count.
+ * Returns a Map from translation_group to count.
+ *
+ * Pass translation_groups (not term ids) — `content_taxonomies.taxonomy_id`
+ * stores the translation_group so a single assignment spans every locale.
*/
- async countEntriesForTerms(termIds: string[]): Promise> {
- if (termIds.length === 0) return new Map();
+ async countEntriesForTerms(translationGroups: string[]): Promise> {
+ if (translationGroups.length === 0) return new Map();
const { chunks, SQL_BATCH_SIZE } = await import("../../utils/chunks.js");
const counts = new Map();
- for (const chunk of chunks(termIds, SQL_BATCH_SIZE)) {
+ for (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) {
const rows = await this.db
.selectFrom("content_taxonomies")
.select(["taxonomy_id", (eb) => eb.fn.count("entry_id").as("count")])
@@ -315,10 +420,7 @@ export class TaxonomyRepository {
return counts;
}
- /**
- * Convert database row to Taxonomy object
- */
- private rowToTaxonomy(row: TaxonomyTable): Taxonomy {
+ private rowToTaxonomy(row: Selectable): Taxonomy {
return {
id: row.id,
name: row.name,
@@ -326,6 +428,8 @@ export class TaxonomyRepository {
label: row.label,
parentId: row.parent_id,
data: row.data ? JSON.parse(row.data) : null,
+ locale: row.locale,
+ translationGroup: row.translation_group,
};
}
}
diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts
index 80104d387..ee595f7ff 100644
--- a/packages/core/src/database/types.ts
+++ b/packages/core/src/database/types.ts
@@ -20,12 +20,14 @@ export interface TaxonomyTable {
label: string;
parent_id: string | null;
data: string | null; // JSON
+ locale: Generated; // e.g. 'en', 'es', 'fr'
+ translation_group: string | null; // shared across translations of the same term
}
export interface ContentTaxonomyTable {
collection: string; // e.g., 'posts'
entry_id: string; // ID in the ec_* table
- taxonomy_id: string;
+ taxonomy_id: string; // stores taxonomies.translation_group (locale-agnostic)
}
export interface TaxonomyDefTable {
@@ -36,6 +38,8 @@ export interface TaxonomyDefTable {
hierarchical: number; // 0 or 1 (SQLite boolean)
collections: string | null; // JSON array
created_at: Generated;
+ locale: Generated;
+ translation_group: string | null;
}
export interface MediaTable {
@@ -292,6 +296,8 @@ export interface MenuTable {
label: string;
created_at: Generated;
updated_at: Generated;
+ locale: Generated;
+ translation_group: string | null;
}
export interface MenuItemTable {
@@ -301,13 +307,15 @@ export interface MenuItemTable {
sort_order: number;
type: string;
reference_collection: string | null;
- reference_id: string | null;
+ reference_id: string | null; // stores translation_group of referenced content/term
custom_url: string | null;
label: string;
title_attr: string | null;
target: string | null;
css_classes: string | null;
created_at: Generated;
+ locale: Generated;
+ translation_group: string | null;
}
// Widget Areas
diff --git a/packages/core/src/i18n/resolve.ts b/packages/core/src/i18n/resolve.ts
new file mode 100644
index 000000000..95bddcd4b
--- /dev/null
+++ b/packages/core/src/i18n/resolve.ts
@@ -0,0 +1,37 @@
+/**
+ * Shared locale-resolution helpers.
+ *
+ * Matches the pattern used by `query.ts` for content: an explicit locale wins,
+ * otherwise we fall back to the request-context locale, otherwise to
+ * `defaultLocale` when i18n is enabled, otherwise to `undefined` (meaning "do
+ * not filter by locale" — legacy single-locale behaviour).
+ */
+
+import { getRequestContext } from "../request-context.js";
+import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./config.js";
+
+/**
+ * Resolve the locale to use for a query given an optional explicit value.
+ * Returns `undefined` when no locale information is available; callers should
+ * treat that as "do not filter by locale".
+ */
+export function resolveLocale(explicit?: string): string | undefined {
+ if (explicit !== undefined) return explicit;
+ const ctxLocale = getRequestContext()?.locale;
+ if (ctxLocale !== undefined) return ctxLocale;
+ const cfg = getI18nConfig();
+ if (cfg && isI18nEnabled()) return cfg.defaultLocale;
+ return undefined;
+}
+
+/**
+ * Fallback chain to try when looking up a single item. When i18n is disabled
+ * or the locale is unspecified, returns a single-element array (or empty when
+ * no locale resolves) so callers can iterate uniformly.
+ */
+export function resolveLocaleChain(explicit?: string): string[] {
+ const locale = resolveLocale(explicit);
+ if (locale === undefined) return [];
+ if (!isI18nEnabled()) return [locale];
+ return getFallbackChain(locale);
+}
diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts
index b141b0391..a41dff628 100644
--- a/packages/core/src/mcp/server.ts
+++ b/packages/core/src/mcp/server.ts
@@ -1667,16 +1667,19 @@ export function createMcpServer(): McpServer {
description:
"List all taxonomy definitions (e.g. categories, tags). Taxonomies are " +
"classification systems applied to content. Each has a name, label, and " +
- "can be hierarchical (categories) or flat (tags).",
- inputSchema: z.object({}),
+ "can be hierarchical (categories) or flat (tags). Optionally filter by " +
+ "locale.",
+ inputSchema: z.object({
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
+ }),
annotations: { readOnlyHint: true },
},
- async (_args, extra) => {
+ async (args, extra) => {
requireScope(extra, "content:read");
const ec = getEmDash(extra);
try {
const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
- return unwrap(await handleTaxonomyList(ec.db));
+ return unwrap(await handleTaxonomyList(ec.db, { locale: args.locale }));
} catch (error) {
return respondHandlerError(error, "TAXONOMY_LIST_ERROR");
}
@@ -1695,6 +1698,7 @@ export function createMcpServer(): McpServer {
taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
}),
annotations: { readOnlyHint: true },
},
@@ -1702,9 +1706,8 @@ export function createMcpServer(): McpServer {
requireScope(extra, "content:read");
const ec = getEmDash(extra);
try {
- // Verify taxonomy exists via handler layer
const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
- const listResult = await handleTaxonomyList(ec.db);
+ const listResult = await handleTaxonomyList(ec.db, { locale: args.locale });
if (!listResult.success) return unwrap(listResult);
const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
@@ -1712,13 +1715,12 @@ export function createMcpServer(): McpServer {
const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
if (!taxonomy) return respondError("NOT_FOUND", `Taxonomy '${args.taxonomy}' not found`);
- // Paginated term query via repository (avoids N+1 of handleTermList)
const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
const { decodeCursor, encodeCursor, InvalidCursorError } =
await import("../database/repositories/types.js");
const repo = new TaxonomyRepository(ec.db);
const limit = Math.min(args.limit ?? 50, 100);
- const terms = await repo.findByName(args.taxonomy);
+ const terms = await repo.findByName(args.taxonomy, { locale: args.locale });
// Manual keyset pagination over the sorted-by-label results.
// Using a base64-encoded `(label, id)` cursor matches the
@@ -1760,6 +1762,8 @@ export function createMcpServer(): McpServer {
label: t.label,
parentId: t.parentId,
description: typeof t.data?.description === "string" ? t.data.description : undefined,
+ locale: t.locale,
+ translationGroup: t.translationGroup,
})),
nextCursor,
});
@@ -1785,6 +1789,11 @@ export function createMcpServer(): McpServer {
label: z.string().describe("Display name"),
parentId: z.string().optional().describe("Parent term ID for hierarchical taxonomies"),
description: z.string().optional().describe("Description of the term"),
+ locale: z.string().optional().describe("Locale for the new term (e.g. 'es')"),
+ translationOf: z
+ .string()
+ .optional()
+ .describe("Term id to join as a translation (same translation_group)"),
}),
},
async (args, extra) => {
@@ -1799,6 +1808,8 @@ export function createMcpServer(): McpServer {
label: args.label,
parentId: args.parentId,
description: args.description,
+ locale: args.locale,
+ translationOf: args.translationOf,
}),
);
} catch (error) {
@@ -1875,6 +1886,29 @@ export function createMcpServer(): McpServer {
},
);
+ server.registerTool(
+ "taxonomy_term_translations",
+ {
+ title: "List Term Translations",
+ description:
+ "Return every locale variant of a taxonomy term, identified via its shared translation_group.",
+ inputSchema: z.object({
+ id: z.string().describe("Term id (or translation_group)"),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ async (args, extra) => {
+ requireScope(extra, "content:read");
+ const ec = getEmDash(extra);
+ try {
+ const { handleTermTranslations } = await import("../api/handlers/taxonomies.js");
+ return unwrap(await handleTermTranslations(ec.db, args.id));
+ } catch (error) {
+ return respondHandlerError(error, "TERM_TRANSLATIONS_ERROR");
+ }
+ },
+ );
+
// =====================================================================
// Menu tools
// =====================================================================
@@ -1884,18 +1918,20 @@ export function createMcpServer(): McpServer {
{
title: "List Menus",
description:
- "List all navigation menus defined in the CMS. Menus are named " +
- "navigation structures (e.g. 'main', 'footer') containing ordered " +
- "items with labels, URLs, and optional nesting.",
- inputSchema: z.object({}),
+ "List navigation menus. Menus are per-locale: filter by `locale` to " +
+ "get just one locale's worth, or omit to list every row (one per " +
+ "locale per menu name).",
+ inputSchema: z.object({
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
+ }),
annotations: { readOnlyHint: true },
},
- async (_args, extra) => {
+ async (args, extra) => {
requireScope(extra, "content:read");
const ec = getEmDash(extra);
try {
const { handleMenuList } = await import("../api/handlers/menus.js");
- return unwrap(await handleMenuList(ec.db));
+ return unwrap(await handleMenuList(ec.db, { locale: args.locale }));
} catch (error) {
return respondHandlerError(error, "MENU_LIST_ERROR");
}
@@ -1907,11 +1943,11 @@ export function createMcpServer(): McpServer {
{
title: "Get Menu with Items",
description:
- "Get a menu by name including all its items in order. Items have a " +
- "label, URL, type (custom/content/collection), and optional parent " +
- "for nesting.",
+ "Get a menu by name, including its items. When multiple locales exist, " +
+ "pass `locale` to pick the right one.",
inputSchema: z.object({
name: z.string().describe("Menu name (e.g. 'main', 'footer')"),
+ locale: z.string().optional().describe("Locale to resolve the menu for"),
}),
annotations: { readOnlyHint: true },
},
@@ -1920,13 +1956,36 @@ export function createMcpServer(): McpServer {
const ec = getEmDash(extra);
try {
const { handleMenuGet } = await import("../api/handlers/menus.js");
- return unwrap(await handleMenuGet(ec.db, args.name));
+ return unwrap(await handleMenuGet(ec.db, args.name, { locale: args.locale }));
} catch (error) {
return respondHandlerError(error, "MENU_GET_ERROR");
}
},
);
+ server.registerTool(
+ "menu_translations",
+ {
+ title: "List Menu Translations",
+ description:
+ "Return every locale variant of a menu, identified via the shared translation_group.",
+ inputSchema: z.object({
+ id: z.string().describe("Menu id (or translation_group)"),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ async (args, extra) => {
+ requireScope(extra, "content:read");
+ const ec = getEmDash(extra);
+ try {
+ const { handleMenuTranslations } = await import("../api/handlers/menus.js");
+ return unwrap(await handleMenuTranslations(ec.db, args.id));
+ } catch (error) {
+ return respondHandlerError(error, "MENU_TRANSLATIONS_ERROR");
+ }
+ },
+ );
+
server.registerTool(
"menu_create",
{
diff --git a/packages/core/src/menus/index.ts b/packages/core/src/menus/index.ts
index cacb2d2ae..bb1202eef 100644
--- a/packages/core/src/menus/index.ts
+++ b/packages/core/src/menus/index.ts
@@ -1,7 +1,11 @@
/**
- * Navigation menu runtime functions
+ * Navigation menu runtime functions.
*
- * These are called from templates to query menus and resolve URLs.
+ * These are called from templates to query menus and resolve URLs. All queries
+ * are locale-aware: when a locale is configured (or passed explicitly) items
+ * are filtered to that locale, and menu item references resolve against the
+ * referenced content's translation_group so the URL points at the right
+ * per-locale row.
*/
import type { Kysely } from "kysely";
@@ -9,50 +13,61 @@ import { sql } from "kysely";
import type { Database } from "../database/types.js";
import { validateIdentifier } from "../database/validate.js";
+import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js";
import { getDb } from "../loader.js";
import { requestCached } from "../request-cache.js";
import { sanitizeHref } from "../utils/url.js";
import type { Menu, MenuItem, MenuItemRow } from "./types.js";
+export interface MenuQueryOptions {
+ /** Override the locale used for the lookup. When omitted, the locale comes
+ * from the request context or the configured defaultLocale. */
+ locale?: string;
+}
+
/**
- * Get menu by name with resolved URLs
+ * Get a menu by name with resolved URLs.
*
* @example
* ```ts
- * import { getMenu } from "emdash";
- *
* const menu = await getMenu("primary");
- * if (menu) {
- * console.log(menu.items); // Array of MenuItem with resolved URLs
- * }
+ * const menuEs = await getMenu("primary", { locale: "es" });
* ```
*/
-export function getMenu(name: string): Promise {
- return requestCached(`menu:${name}`, async () => {
+export function getMenu(name: string, options: MenuQueryOptions = {}): Promise {
+ const locale = resolveLocale(options.locale);
+ return requestCached(`menu:${name}:${locale ?? "*"}`, async () => {
const db = await getDb();
- return getMenuWithDb(name, db);
+ return getMenuWithDb(name, db, { locale });
});
}
/**
- * Get menu by name with resolved URLs (with explicit db)
- *
- * @internal Use `getMenu()` in templates. This variant is for admin routes
- * that already have a database handle.
+ * Get menu by name with resolved URLs (with explicit db). Internal helper for
+ * admin routes that already have a database handle.
*/
-export async function getMenuWithDb(name: string, db: Kysely): Promise {
- // Get menu
- const menuRow = await db
- .selectFrom("_emdash_menus")
- .selectAll()
- .where("name", "=", name)
- .executeTakeFirst();
-
- if (!menuRow) {
- return null;
+export async function getMenuWithDb(
+ name: string,
+ db: Kysely,
+ options: MenuQueryOptions = {},
+): Promise {
+ const chain = resolveLocaleChain(options.locale);
+
+ const selectMenu = () => db.selectFrom("_emdash_menus").selectAll().where("name", "=", name);
+
+ let menuRow: Awaited["executeTakeFirst"]>>;
+ if (chain.length === 0) {
+ menuRow = await selectMenu().orderBy("locale", "asc").executeTakeFirst();
+ } else {
+ menuRow = undefined;
+ for (const locale of chain) {
+ menuRow = await selectMenu().where("locale", "=", locale).executeTakeFirst();
+ if (menuRow) break;
+ }
}
- // Get all menu items
+ if (!menuRow) return null;
+
const itemRows = await db
.selectFrom("_emdash_menu_items")
.selectAll()
@@ -61,31 +76,27 @@ export async function getMenuWithDb(name: string, db: Kysely): Promise
.orderBy("sort_order", "asc")
.execute();
- // Resolve URLs and build tree
- const items = await buildMenuTree(itemRows, db);
+ const items = await buildMenuTree(itemRows, db, menuRow.locale);
return {
id: menuRow.id,
name: menuRow.name,
label: menuRow.label,
items,
+ locale: menuRow.locale,
+ translationGroup: menuRow.translation_group,
};
}
/**
- * Get all menus (without items - for admin list)
- *
- * @example
- * ```ts
- * import { getMenus } from "emdash";
- *
- * const menus = await getMenus();
- * console.log(menus); // [{ id, name, label }]
- * ```
+ * Get all menus (without items, locale-filtered — for admin list / site nav
+ * summaries). When no locale is configured, returns menus across all locales.
*/
-export async function getMenus(): Promise> {
+export async function getMenus(
+ options: MenuQueryOptions = {},
+): Promise> {
const db = await getDb();
- return getMenusWithDb(db);
+ return getMenusWithDb(db, options);
}
/**
@@ -96,26 +107,30 @@ export async function getMenus(): Promise,
-): Promise> {
- const rows = await db
+ options: MenuQueryOptions = {},
+): Promise> {
+ const locale = resolveLocale(options.locale);
+ let query = db
.selectFrom("_emdash_menus")
- .select(["id", "name", "label"])
- .orderBy("name", "asc")
- .execute();
-
- return rows;
+ .select(["id", "name", "label", "locale"])
+ .orderBy("name", "asc");
+ if (locale !== undefined) query = query.where("locale", "=", locale);
+ return query.execute();
}
/**
- * Build hierarchical menu tree from flat array of items
+ * Build a hierarchical menu tree from a flat list of items. Items are
+ * resolved against the given `locale` so references land on the right
+ * per-locale content rows.
*/
-async function buildMenuTree(items: MenuItemRow[], db: Kysely): Promise {
- // Pre-load URL patterns for all collections referenced in this menu
+async function buildMenuTree(
+ items: MenuItemRow[],
+ db: Kysely,
+ locale: string,
+): Promise {
const collectionSlugs = new Set();
for (const item of items) {
- if (item.reference_collection) {
- collectionSlugs.add(item.reference_collection);
- }
+ if (item.reference_collection) collectionSlugs.add(item.reference_collection);
if (item.type === "page" || item.type === "post") {
collectionSlugs.add(item.reference_collection || `${item.type}s`);
}
@@ -128,41 +143,28 @@ async function buildMenuTree(items: MenuItemRow[], db: Kysely): Promis
.select(["slug", "url_pattern"])
.where("slug", "in", [...collectionSlugs])
.execute();
- for (const row of rows) {
- urlPatterns.set(row.slug, row.url_pattern);
- }
+ for (const row of rows) urlPatterns.set(row.slug, row.url_pattern);
}
- // Resolve all URLs first
const resolvedItems = await Promise.all(
- items.map((item) => resolveMenuItem(item, db, urlPatterns)),
+ items.map((item) => resolveMenuItem(item, db, urlPatterns, locale)),
);
+ const validItems = resolvedItems.filter((item): item is MenuItem => item !== null);
- // Filter out items that couldn't be resolved (e.g., deleted content)
- const validItems = resolvedItems.filter((item) => item !== null);
-
- // Build tree structure
const itemMap = new Map();
const rootItems: MenuItem[] = [];
- // First pass: create all items
for (const item of validItems) {
itemMap.set(item.id, { ...item, children: [] });
}
- // Second pass: build parent-child relationships
for (const item of items) {
const menuItem = itemMap.get(item.id);
if (!menuItem) continue;
-
if (item.parent_id) {
const parent = itemMap.get(item.parent_id);
- if (parent) {
- parent.children.push(menuItem);
- } else {
- // Parent not found, treat as root
- rootItems.push(menuItem);
- }
+ if (parent) parent.children.push(menuItem);
+ else rootItems.push(menuItem);
} else {
rootItems.push(menuItem);
}
@@ -172,14 +174,15 @@ async function buildMenuTree(items: MenuItemRow[], db: Kysely): Promis
}
/**
- * Resolve a single menu item's URL
- *
- * Returns null if the referenced content no longer exists (item should be skipped)
+ * Resolve a single menu item's URL. `reference_id` is a translation_group
+ * (migration 036 remapped all existing references); we join it against
+ * the per-locale ec_* row or per-locale taxonomy row.
*/
async function resolveMenuItem(
item: MenuItemRow,
db: Kysely,
urlPatterns: Map,
+ locale: string,
): Promise {
let url: string | null;
@@ -192,24 +195,18 @@ async function resolveMenuItem(
case "page":
case "post":
url = await resolveContentUrl(
- // Default to plural collection name (pages/posts) if not specified
item.reference_collection || `${item.type}s`,
item.reference_id,
db,
urlPatterns,
+ locale,
);
- // Skip items where content no longer exists
- if (url === null) {
- return null;
- }
+ if (url === null) return null;
break;
case "taxonomy":
- url = await resolveTaxonomyUrl(item.reference_id, db);
- // Skip items where taxonomy no longer exists
- if (url === null) {
- return null;
- }
+ url = await resolveTaxonomyUrl(item.reference_id, db, locale);
+ if (url === null) return null;
break;
case "collection":
@@ -223,16 +220,14 @@ async function resolveMenuItem(
item.reference_id,
db,
urlPatterns,
+ locale,
);
- if (url === null) {
- return null;
- }
+ if (url === null) return null;
} else {
url = "#";
}
}
} catch (error) {
- // If resolution fails, skip this item
console.error(`Failed to resolve menu item ${item.id}:`, error);
return null;
}
@@ -244,7 +239,7 @@ async function resolveMenuItem(
target: item.target || undefined,
titleAttr: item.title_attr || undefined,
cssClasses: item.css_classes || undefined,
- children: [], // Will be populated by buildMenuTree
+ children: [],
};
}
@@ -261,72 +256,96 @@ function interpolateUrlPattern(pattern: string, slug: string, id: string): strin
}
/**
- * Resolve URL for a content entry (page/post)
- *
- * Uses the collection's url_pattern if set, otherwise falls back to /{collection}/{slug}.
- * Returns null if content not found (item should be skipped).
+ * Resolve the URL for a content reference. `referenceGroup` is the content
+ * row's translation_group; we look up the row in the requested locale
+ * (falling back to the source if no translation exists so the menu link is
+ * still clickable).
*/
async function resolveContentUrl(
collection: string,
- entryId: string | null,
+ referenceGroup: string | null,
db: Kysely,
urlPatterns: Map,
+ locale: string,
): Promise {
- if (!entryId) {
- return null;
- }
+ if (!referenceGroup) return null;
try {
- // Validate collection name before interpolating into table reference
validateIdentifier(collection, "menu item collection");
- // Dynamic content tables (ec_*) aren't in the Database type, so use sql
- const result = await sql<{ slug: string }>`
- SELECT slug FROM ${sql.ref(`ec_${collection}`)} WHERE id = ${entryId} LIMIT 1
+ // Try the requested locale first, then any locale (deterministic).
+ let result = await sql<{ id: string; slug: string }>`
+ SELECT id, slug FROM ${sql.ref(`ec_${collection}`)}
+ WHERE translation_group = ${referenceGroup} AND locale = ${locale}
+ LIMIT 1
`.execute(db);
-
- const row = result.rows[0];
- if (row) {
- const pattern = urlPatterns.get(collection);
- if (pattern) {
- return interpolateUrlPattern(pattern, row.slug, entryId);
- }
- return `/${collection}/${row.slug}`;
+ let row = result.rows[0];
+ if (!row) {
+ result = await sql<{ id: string; slug: string }>`
+ SELECT id, slug FROM ${sql.ref(`ec_${collection}`)}
+ WHERE translation_group = ${referenceGroup}
+ ORDER BY locale ASC LIMIT 1
+ `.execute(db);
+ row = result.rows[0];
}
+ if (!row) {
+ // Legacy rows whose reference_id still points at an id directly
+ // (defensive — migration 036 normalised these, but a row inserted
+ // between migrations could predate the remap).
+ const legacy = await sql<{ id: string; slug: string }>`
+ SELECT id, slug FROM ${sql.ref(`ec_${collection}`)}
+ WHERE id = ${referenceGroup} LIMIT 1
+ `.execute(db);
+ row = legacy.rows[0];
+ }
+ if (!row) return null;
- // Content not found, skip item
- return null;
+ const pattern = urlPatterns.get(collection);
+ if (pattern) return interpolateUrlPattern(pattern, row.slug, row.id);
+ return `/${collection}/${row.slug}`;
} catch (error) {
- // Table might not exist or query failed
- console.error(`Failed to resolve content URL for ${collection}/${entryId}:`, error);
+ console.error(`Failed to resolve content URL for ${collection}/${referenceGroup}:`, error);
return null;
}
}
/**
- * Resolve URL for a taxonomy term
- *
- * Returns null if taxonomy not found (item should be skipped)
+ * Resolve URL for a taxonomy term reference. `referenceGroup` is the term's
+ * translation_group; we pick the row in the active locale (or fall back).
*/
async function resolveTaxonomyUrl(
- taxonomyId: string | null,
+ referenceGroup: string | null,
db: Kysely,
+ locale: string,
): Promise {
- if (!taxonomyId) {
- return null;
- }
+ if (!referenceGroup) return null;
- const taxonomy = await db
+ let taxonomy = await db
.selectFrom("taxonomies")
.select(["name", "slug"])
- .where("id", "=", taxonomyId)
+ .where("translation_group", "=", referenceGroup)
+ .where("locale", "=", locale)
.executeTakeFirst();
if (!taxonomy) {
- // Taxonomy not found, skip item
- return null;
+ taxonomy = await db
+ .selectFrom("taxonomies")
+ .select(["name", "slug"])
+ .where("translation_group", "=", referenceGroup)
+ .orderBy("locale", "asc")
+ .executeTakeFirst();
}
- // Use taxonomy name as base (e.g., "categories" or "tags")
+ if (!taxonomy) {
+ // Legacy: id-based reference that predates the migration remap.
+ taxonomy = await db
+ .selectFrom("taxonomies")
+ .select(["name", "slug"])
+ .where("id", "=", referenceGroup)
+ .executeTakeFirst();
+ }
+
+ if (!taxonomy) return null;
+
return `/${taxonomy.name}/${taxonomy.slug}`;
}
diff --git a/packages/core/src/menus/types.ts b/packages/core/src/menus/types.ts
index 9f9b10c7b..6825035bb 100644
--- a/packages/core/src/menus/types.ts
+++ b/packages/core/src/menus/types.ts
@@ -24,6 +24,8 @@ export interface Menu {
name: string;
label: string;
items: MenuItem[];
+ locale: string;
+ translationGroup: string | null;
}
/**
@@ -36,13 +38,15 @@ export interface MenuItemRow {
sort_order: number;
type: MenuItemType;
reference_collection: string | null;
- reference_id: string | null;
+ reference_id: string | null; // translation_group of referenced content/term
custom_url: string | null;
label: string;
title_attr: string | null;
target: string | null;
css_classes: string | null;
created_at: string;
+ locale: string;
+ translation_group: string | null;
}
/**
@@ -54,6 +58,8 @@ export interface MenuRow {
label: string;
created_at: string;
updated_at: string;
+ locale: string;
+ translation_group: string | null;
}
/**
@@ -62,6 +68,11 @@ export interface MenuRow {
export interface CreateMenuItemInput {
type: MenuItemType;
label: string;
+ /**
+ * Identifier of the referenced entity. For `reference_collection` items it is
+ * the content's translation_group (locale-agnostic); for `taxonomy` items it
+ * is the term's translation_group.
+ */
referenceCollection?: string;
referenceId?: string;
customUrl?: string;
@@ -91,6 +102,9 @@ export interface UpdateMenuItemInput {
export interface CreateMenuInput {
name: string;
label: string;
+ locale?: string;
+ /** When set, links the new menu into an existing translation_group. */
+ translationOf?: string;
}
/**
diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts
index 553eeadae..d051be085 100644
--- a/packages/core/src/seed/apply.ts
+++ b/packages/core/src/seed/apply.ts
@@ -19,6 +19,7 @@ import { TaxonomyRepository } from "../database/repositories/taxonomy.js";
import { withTransaction } from "../database/transaction.js";
import type { Database } from "../database/types.js";
import type { MediaValue } from "../fields/types.js";
+import { getI18nConfig } from "../i18n/config.js";
import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js";
import { SchemaRegistry } from "../schema/registry.js";
import { FTSManager } from "../search/fts-manager.js";
@@ -219,17 +220,30 @@ export async function applySeed(
// 4-5. Taxonomies
if (seed.taxonomies) {
+ // seed-local id -> resolved info, used to wire `translationOf` refs.
+ const defSeedIdMap = new Map();
+ const termSeedIdMap = new Map();
+ const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
+
for (const taxonomy of seed.taxonomies) {
- // Check if taxonomy definition exists
+ const defLocale = taxonomy.locale ?? fallbackLocale;
+
+ // (name, locale) is the UNIQUE key after migration 036.
const existingDef = await db
.selectFrom("_emdash_taxonomy_defs")
.selectAll()
.where("name", "=", taxonomy.name)
+ .where("locale", "=", defLocale)
.executeTakeFirst();
+ let defId: string;
+ let defTranslationGroup: string;
+
if (existingDef) {
+ defId = existingDef.id;
+ defTranslationGroup = existingDef.translation_group ?? existingDef.id;
if (onConflict === "error") {
- throw new Error(`Conflict: taxonomy "${taxonomy.name}" already exists`);
+ throw new Error(`Conflict: taxonomy "${taxonomy.name}" (${defLocale}) already exists`);
}
if (onConflict === "update") {
await db
@@ -242,40 +256,59 @@ export async function applySeed(
})
.where("id", "=", existingDef.id)
.execute();
- // Taxonomy defs don't track an "updated" counter -- just the definition is updated
}
- // skip: do nothing for the definition
} else {
- // Create taxonomy definition
+ defId = ulid();
+ defTranslationGroup = defId;
+ if (taxonomy.translationOf) {
+ const source = defSeedIdMap.get(taxonomy.translationOf);
+ if (source) defTranslationGroup = source.translationGroup;
+ else
+ console.warn(
+ `taxonomy "${taxonomy.name}" (${defLocale}): translationOf "${taxonomy.translationOf}" not found yet; minting a fresh group.`,
+ );
+ }
await db
.insertInto("_emdash_taxonomy_defs")
.values({
- id: ulid(),
+ id: defId,
name: taxonomy.name,
label: taxonomy.label,
label_singular: taxonomy.labelSingular ?? null,
hierarchical: taxonomy.hierarchical ? 1 : 0,
collections: JSON.stringify(taxonomy.collections),
+ locale: defLocale,
+ translation_group: defTranslationGroup,
})
.execute();
result.taxonomies.created++;
}
+ if (taxonomy.id)
+ defSeedIdMap.set(taxonomy.id, { id: defId, translationGroup: defTranslationGroup });
+
// Create terms (if provided)
if (taxonomy.terms && taxonomy.terms.length > 0) {
const termRepo = new TaxonomyRepository(db);
- // For hierarchical taxonomies, we need to create parents before children
if (taxonomy.hierarchical) {
- await applyHierarchicalTerms(termRepo, taxonomy.name, taxonomy.terms, result, onConflict);
+ await applyHierarchicalTerms(
+ termRepo,
+ taxonomy.name,
+ defLocale,
+ taxonomy.terms,
+ termSeedIdMap,
+ result,
+ onConflict,
+ );
} else {
- // Flat taxonomy - create all terms
for (const term of taxonomy.terms) {
- const existing = await termRepo.findBySlug(taxonomy.name, term.slug);
+ const termLocale = term.locale ?? defLocale;
+ const existing = await termRepo.findBySlug(taxonomy.name, term.slug, termLocale);
if (existing) {
if (onConflict === "error") {
throw new Error(
- `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" already exists`,
+ `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" (${termLocale}) already exists`,
);
}
if (onConflict === "update") {
@@ -285,14 +318,20 @@ export async function applySeed(
});
result.taxonomies.terms++;
}
- // skip: do nothing
+ if (term.id) termSeedIdMap.set(term.id, existing.id);
} else {
- await termRepo.create({
+ const translationOf = term.translationOf
+ ? termSeedIdMap.get(term.translationOf)
+ : undefined;
+ const created = await termRepo.create({
name: taxonomy.name,
slug: term.slug,
label: term.label,
data: term.description ? { description: term.description } : undefined,
+ locale: termLocale,
+ translationOf,
});
+ if (term.id) termSeedIdMap.set(term.id, created.id);
result.taxonomies.terms++;
}
}
@@ -471,23 +510,39 @@ export async function applySeed(
// 8. Menus and Menu Items (after content so refs can resolve)
if (seed.menus) {
+ // seed-local id -> resolved info, used to wire `translationOf` refs.
+ const menuSeedIdMap = new Map();
+ const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
+
for (const menu of seed.menus) {
- // Check if menu exists
- const existingMenu = await db
+ const locale = menu.locale ?? fallbackLocale;
+ let lookup = db
.selectFrom("_emdash_menus")
.selectAll()
.where("name", "=", menu.name)
- .executeTakeFirst();
+ .where("locale", "=", locale);
+ const existingMenu = await lookup.executeTakeFirst();
let menuId: string;
+ let translationGroup: string;
if (existingMenu) {
menuId = existingMenu.id;
+ translationGroup = existingMenu.translation_group ?? existingMenu.id;
// Clear existing items (menus are recreated)
await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menuId).execute();
} else {
- // Create menu
menuId = ulid();
+ // Resolve translationOf to the source menu's translation_group.
+ translationGroup = menuId;
+ if (menu.translationOf) {
+ const source = menuSeedIdMap.get(menu.translationOf);
+ if (source) translationGroup = source.translationGroup;
+ else
+ console.warn(
+ `menu "${menu.name}" (${locale}): translationOf "${menu.translationOf}" not found yet; minting a fresh group.`,
+ );
+ }
await db
.insertInto("_emdash_menus")
.values({
@@ -496,15 +551,20 @@ export async function applySeed(
label: menu.label,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
+ locale,
+ translation_group: translationGroup,
})
.execute();
result.menus.created++;
}
+ if (menu.id) menuSeedIdMap.set(menu.id, { id: menuId, translationGroup });
+
// Create menu items
const itemCount = await applyMenuItems(
db,
menuId,
+ locale,
menu.items,
null, // parent_id
0, // sort_order
@@ -692,64 +752,75 @@ export async function applySeed(
async function applyHierarchicalTerms(
termRepo: TaxonomyRepository,
taxonomyName: string,
+ defLocale: string,
terms: SeedTaxonomyTerm[],
+ termSeedIdMap: Map,
result: SeedApplyResult,
onConflict: "skip" | "update" | "error" = "skip",
): Promise {
- // Map slugs to IDs
+ // "locale::slug" -> id, so the same slug can resolve per locale.
const slugToId = new Map();
- // Multiple passes to handle deep nesting
+ // Multiple passes — handles deep nesting and translationOf forward refs.
let remaining = [...terms];
- let maxPasses = 10; // Prevent infinite loop
+ let maxPasses = 10;
while (remaining.length > 0 && maxPasses > 0) {
const processedThisPass: string[] = [];
for (const term of remaining) {
- // Check if parent exists (or no parent)
- if (!term.parent || slugToId.has(term.parent)) {
- const parentId = term.parent ? slugToId.get(term.parent) : undefined;
+ const termLocale = term.locale ?? defLocale;
+ const parentReady = !term.parent || slugToId.has(`${termLocale}::${term.parent}`);
+ const translationReady = !term.translationOf || termSeedIdMap.has(term.translationOf);
- const existing = await termRepo.findBySlug(taxonomyName, term.slug);
- if (existing) {
- if (onConflict === "error") {
- throw new Error(
- `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" already exists`,
- );
- }
- if (onConflict === "update") {
- await termRepo.update(existing.id, {
- label: term.label,
- parentId,
- data: term.description ? { description: term.description } : {},
- });
- result.taxonomies.terms++;
- }
- slugToId.set(term.slug, existing.id);
- } else {
- const created = await termRepo.create({
- name: taxonomyName,
- slug: term.slug,
+ if (!parentReady || !translationReady) continue;
+
+ const parentId = term.parent ? slugToId.get(`${termLocale}::${term.parent}`) : undefined;
+ const translationOf = term.translationOf ? termSeedIdMap.get(term.translationOf) : undefined;
+
+ const existing = await termRepo.findBySlug(taxonomyName, term.slug, termLocale);
+ if (existing) {
+ if (onConflict === "error") {
+ throw new Error(
+ `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" (${termLocale}) already exists`,
+ );
+ }
+ if (onConflict === "update") {
+ await termRepo.update(existing.id, {
label: term.label,
parentId,
- data: term.description ? { description: term.description } : undefined,
+ data: term.description ? { description: term.description } : {},
});
- slugToId.set(term.slug, created.id);
result.taxonomies.terms++;
}
-
- processedThisPass.push(term.slug);
+ slugToId.set(`${termLocale}::${term.slug}`, existing.id);
+ if (term.id) termSeedIdMap.set(term.id, existing.id);
+ } else {
+ const created = await termRepo.create({
+ name: taxonomyName,
+ slug: term.slug,
+ label: term.label,
+ parentId,
+ data: term.description ? { description: term.description } : undefined,
+ locale: termLocale,
+ translationOf,
+ });
+ slugToId.set(`${termLocale}::${term.slug}`, created.id);
+ if (term.id) termSeedIdMap.set(term.id, created.id);
+ result.taxonomies.terms++;
}
+
+ processedThisPass.push(term.slug + "::" + termLocale);
}
- // Remove processed terms
- remaining = remaining.filter((t) => !processedThisPass.includes(t.slug));
+ remaining = remaining.filter(
+ (t) => !processedThisPass.includes(t.slug + "::" + (t.locale ?? defLocale)),
+ );
maxPasses--;
}
if (remaining.length > 0) {
- console.warn(`Could not process ${remaining.length} terms due to missing parents`);
+ console.warn(`Could not process ${remaining.length} terms due to missing parents/translations`);
}
}
@@ -847,11 +918,18 @@ async function applyContentTaxonomies(
}
/**
- * Apply menu items recursively
+ * Apply menu items recursively.
+ *
+ * Each item gets a fresh `translation_group` (= its own id). The seed format's
+ * `SeedMenuItem` has no `id`/`translationOf` fields, so we can't express the
+ * cross-locale "same nav entry" link here — items diverge across locales on
+ * re-apply. Runtime navigation still resolves correctly because `reference_id`
+ * already holds the content's translation_group.
*/
async function applyMenuItems(
db: Kysely,
menuId: string,
+ locale: string,
items: SeedMenuItem[],
parentId: string | null,
startOrder: number,
@@ -877,7 +955,6 @@ async function applyMenuItems(
// If not in map, the content might not exist yet (will be broken link)
}
- // Insert menu item
await db
.insertInto("_emdash_menu_items")
.values({
@@ -894,15 +971,24 @@ async function applyMenuItems(
target: item.target ?? null,
css_classes: item.cssClasses ?? null,
created_at: new Date().toISOString(),
+ locale,
+ translation_group: itemId,
})
.execute();
count++;
order++;
- // Process children
if (item.children && item.children.length > 0) {
- const childCount = await applyMenuItems(db, menuId, item.children, itemId, 0, seedIdMap);
+ const childCount = await applyMenuItems(
+ db,
+ menuId,
+ locale,
+ item.children,
+ itemId,
+ 0,
+ seedIdMap,
+ );
count += childCount;
}
}
diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts
index 846242df1..b2e7fb040 100644
--- a/packages/core/src/seed/types.ts
+++ b/packages/core/src/seed/types.ts
@@ -87,14 +87,19 @@ export interface SeedField {
}
/**
- * Taxonomy definition in seed
+ * Taxonomy definition in seed. For multi-locale exports each locale variant
+ * is its own entry, linked via `translationOf` (referencing another entry's `id`).
*/
export interface SeedTaxonomy {
+ /** Optional seed-local id, e.g. "tax:category:en". Target of `translationOf`. */
+ id?: string;
name: string;
label: string;
labelSingular?: string;
hierarchical: boolean;
collections: string[];
+ locale?: string;
+ translationOf?: string;
terms?: SeedTaxonomyTerm[];
}
@@ -102,18 +107,26 @@ export interface SeedTaxonomy {
* Taxonomy term in seed
*/
export interface SeedTaxonomyTerm {
+ /** Optional seed-local id, e.g. "term:category:news:en". */
+ id?: string;
slug: string;
label: string;
description?: string;
parent?: string; // Slug of parent term (for hierarchical taxonomies)
+ locale?: string;
+ translationOf?: string;
}
/**
* Menu definition in seed
*/
export interface SeedMenu {
+ /** Optional seed-local id, e.g. "menu:primary:en". */
+ id?: string;
name: string;
label: string;
+ locale?: string;
+ translationOf?: string;
items: SeedMenuItem[];
}
diff --git a/packages/core/src/seed/validate.ts b/packages/core/src/seed/validate.ts
index a7d0d4edf..ac9f15d54 100644
--- a/packages/core/src/seed/validate.ts
+++ b/packages/core/src/seed/validate.ts
@@ -147,11 +147,16 @@ export function validateSeed(data: unknown): ValidationResult {
if (!taxonomy.name) {
errors.push(`${prefix}: name is required`);
} else {
- // Check for duplicate taxonomy names
- if (taxonomyNames.has(taxonomy.name)) {
- errors.push(`${prefix}.name: duplicate taxonomy name "${taxonomy.name}"`);
+ // Uniqueness is per (name, locale).
+ const key = `${taxonomy.name}::${taxonomy.locale ?? ""}`;
+ if (taxonomyNames.has(key)) {
+ errors.push(
+ taxonomy.locale
+ ? `${prefix}.name: duplicate taxonomy "${taxonomy.name}" in locale "${taxonomy.locale}"`
+ : `${prefix}.name: duplicate taxonomy name "${taxonomy.name}"`,
+ );
}
- taxonomyNames.add(taxonomy.name);
+ taxonomyNames.add(key);
}
if (!taxonomy.label) {
@@ -184,13 +189,15 @@ export function validateSeed(data: unknown): ValidationResult {
if (!term.slug) {
errors.push(`${termPrefix}: slug is required`);
} else {
- // Check for duplicate term slugs
- if (termSlugs.has(term.slug)) {
+ // Uniqueness is per (slug, locale) so the same slug can repeat
+ // across locale variants of the def.
+ const key = `${term.slug}::${term.locale ?? taxonomy.locale ?? ""}`;
+ if (termSlugs.has(key)) {
errors.push(
`${termPrefix}.slug: duplicate term slug "${term.slug}" in taxonomy "${taxonomy.name}"`,
);
}
- termSlugs.add(term.slug);
+ termSlugs.add(key);
}
if (!term.label) {
@@ -207,11 +214,12 @@ export function validateSeed(data: unknown): ValidationResult {
}
}
- // Second pass: validate parent references
+ // Second pass: validate parent references (within the same locale).
if (taxonomy.hierarchical && taxonomy.terms) {
for (let j = 0; j < taxonomy.terms.length; j++) {
const term = taxonomy.terms[j];
- if (term.parent && !termSlugs.has(term.parent)) {
+ const termLocale = term.locale ?? taxonomy.locale ?? "";
+ if (term.parent && !termSlugs.has(`${term.parent}::${termLocale}`)) {
errors.push(
`${prefix}.terms[${j}].parent: parent term "${term.parent}" not found in taxonomy`,
);
@@ -243,11 +251,17 @@ export function validateSeed(data: unknown): ValidationResult {
if (!menu.name) {
errors.push(`${prefix}: name is required`);
} else {
- // Check for duplicate menu names
- if (menuNames.has(menu.name)) {
- errors.push(`${prefix}.name: duplicate menu name "${menu.name}"`);
+ // Uniqueness is per (name, locale) — siblings of a translation
+ // group share name but differ in locale.
+ const key = `${menu.name}::${menu.locale ?? ""}`;
+ if (menuNames.has(key)) {
+ errors.push(
+ menu.locale
+ ? `${prefix}.name: duplicate menu "${menu.name}" in locale "${menu.locale}"`
+ : `${prefix}.name: duplicate menu name "${menu.name}"`,
+ );
}
- menuNames.add(menu.name);
+ menuNames.add(key);
}
if (!menu.label) {
diff --git a/packages/core/src/taxonomies/index.ts b/packages/core/src/taxonomies/index.ts
index 93cd7605c..0571045f3 100644
--- a/packages/core/src/taxonomies/index.ts
+++ b/packages/core/src/taxonomies/index.ts
@@ -1,115 +1,134 @@
/**
- * Runtime API for taxonomies
+ * Runtime API for taxonomies.
*
- * Provides functions to query taxonomy definitions and terms.
+ * All helpers are locale-aware. When a locale is not passed explicitly we fall
+ * back to the request context or the configured `defaultLocale` (see
+ * `i18n/resolve.ts`).
+ *
+ * Because `content_taxonomies.taxonomy_id` stores the translation_group (not a
+ * specific term id), the joins here are `taxonomies.translation_group =
+ * content_taxonomies.taxonomy_id` + filter by `taxonomies.locale`, which picks
+ * the right per-locale term.
*/
+import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js";
import { getDb } from "../loader.js";
import { peekRequestCache, requestCached, setRequestCacheEntry } from "../request-cache.js";
import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
import { isMissingTableError } from "../utils/db-errors.js";
import type { TaxonomyDef, TaxonomyTerm, TaxonomyTermRow } from "./types.js";
+export interface TaxonomyQueryOptions {
+ locale?: string;
+}
+
/**
* No-op — kept for API compatibility.
- *
- * Used to invalidate a worker-lifetime "has any term assignments?" probe.
- * That probe added a query on every cold isolate to save one query on
- * sites with zero term assignments (i.e. the wrong tradeoff), so we
- * dropped it. The batch term join below returns an empty map for empty
- * sites at the same cost as the probe, without the pre-check.
*/
export function invalidateTermCache(): void {
// Intentionally empty.
}
/**
- * Get all taxonomy definitions
+ * Get every taxonomy definition. Definitions are per-locale (one row per
+ * locale inside the same translation_group) — by default we resolve to the
+ * active locale.
*/
-export async function getTaxonomyDefs(): Promise {
- return requestCached("taxonomy-defs:all", async () => {
+export async function getTaxonomyDefs(options: TaxonomyQueryOptions = {}): Promise {
+ const locale = resolveLocale(options.locale);
+ return requestCached(`taxonomy-defs:${locale ?? "*"}`, async () => {
const db = await getDb();
-
- const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
-
- return rows.map((row) => ({
- id: row.id,
- name: row.name,
- label: row.label,
- labelSingular: row.label_singular ?? undefined,
- hierarchical: row.hierarchical === 1,
- collections: row.collections ? JSON.parse(row.collections) : [],
- }));
+ let query = db.selectFrom("_emdash_taxonomy_defs").selectAll();
+ if (locale !== undefined) query = query.where("locale", "=", locale);
+ const rows = await query.execute();
+ return rows.map(rowToTaxonomyDef);
});
}
/**
- * Get a single taxonomy definition by name
+ * Get a single taxonomy definition by name. Uses the fallback chain so even
+ * if there is no translation for the active locale we still return something.
*
* If `getTaxonomyDefs()` has already loaded the full list in this request
* (which happens during entry-term hydration on every page that renders a
- * collection), find the matching def in memory rather than running a
- * second `WHERE name=?` query against `_emdash_taxonomy_defs`.
+ * collection), search the matching def in memory rather than running a
+ * second query against `_emdash_taxonomy_defs`.
*/
-export async function getTaxonomyDef(name: string): Promise {
- const allDefs = peekRequestCache("taxonomy-defs:all");
+export async function getTaxonomyDef(
+ name: string,
+ options: TaxonomyQueryOptions = {},
+): Promise {
+ const chain = resolveLocaleChain(options.locale);
+ const peekKey = `taxonomy-defs:${resolveLocale(options.locale) ?? "*"}`;
+ const allDefs = peekRequestCache(peekKey);
if (allDefs) {
- return (await allDefs).find((d) => d.name === name) ?? null;
+ const defs = await allDefs;
+ if (chain.length === 0) return defs.find((d) => d.name === name) ?? null;
+ for (const locale of chain) {
+ const found = defs.find((d) => d.name === name && d.locale === locale);
+ if (found) return found;
+ }
+ return null;
}
- return requestCached(`taxonomy-def:${name}`, async () => {
+ return requestCached(`taxonomy-def:${name}:${chain.join(",")}`, async () => {
const db = await getDb();
- const row = await db
- .selectFrom("_emdash_taxonomy_defs")
- .selectAll()
- .where("name", "=", name)
- .executeTakeFirst();
-
- if (!row) return null;
+ if (chain.length === 0) {
+ const row = await db
+ .selectFrom("_emdash_taxonomy_defs")
+ .selectAll()
+ .where("name", "=", name)
+ .orderBy("locale", "asc")
+ .executeTakeFirst();
+ return row ? rowToTaxonomyDef(row) : null;
+ }
- return {
- id: row.id,
- name: row.name,
- label: row.label,
- labelSingular: row.label_singular ?? undefined,
- hierarchical: row.hierarchical === 1,
- collections: row.collections ? JSON.parse(row.collections) : [],
- };
+ for (const locale of chain) {
+ const row = await db
+ .selectFrom("_emdash_taxonomy_defs")
+ .selectAll()
+ .where("name", "=", name)
+ .where("locale", "=", locale)
+ .executeTakeFirst();
+ if (row) return rowToTaxonomyDef(row);
+ }
+ return null;
});
}
/**
- * Get all terms for a taxonomy (as tree for hierarchical, flat for tags)
+ * All terms of a taxonomy in a specific locale (flat for non-hierarchical,
+ * tree for hierarchical).
*/
-export async function getTaxonomyTerms(taxonomyName: string): Promise {
- return requestCached(`taxonomy-terms:${taxonomyName}`, async () => {
+export async function getTaxonomyTerms(
+ taxonomyName: string,
+ options: TaxonomyQueryOptions = {},
+): Promise {
+ const locale = resolveLocale(options.locale);
+ return requestCached(`taxonomy-terms:${taxonomyName}:${locale ?? "*"}`, async () => {
const db = await getDb();
- // Get taxonomy definition to check if hierarchical
- const def = await getTaxonomyDef(taxonomyName);
+ const def = await getTaxonomyDef(taxonomyName, options);
if (!def) return [];
- // Get all terms for this taxonomy
- const rows = await db
+ let termsQuery = db
.selectFrom("taxonomies")
.selectAll()
.where("name", "=", taxonomyName)
- .orderBy("label", "asc")
- .execute();
+ .orderBy("label", "asc");
+ if (locale !== undefined) termsQuery = termsQuery.where("locale", "=", locale);
+ const rows = await termsQuery.execute();
- // Count entries for each term
+ // Counts are keyed by translation_group (what the pivot stores).
const countsResult = await db
.selectFrom("content_taxonomies")
.select(["taxonomy_id"])
.select((eb) => eb.fn.count("entry_id").as("count"))
.groupBy("taxonomy_id")
.execute();
-
const counts = new Map