diff --git a/.changeset/i18n-menus-and-taxonomies.md b/.changeset/i18n-menus-and-taxonomies.md new file mode 100644 index 000000000..1c084adea --- /dev/null +++ b/.changeset/i18n-menus-and-taxonomies.md @@ -0,0 +1,43 @@ +--- +"emdash": minor +--- + +Adds i18n support to menus and taxonomies (categories, tags, custom +definitions), mirroring the per-locale model already in place for content. +Each row carries `locale` and `translation_group`; translations of the +same menu/term/def share a `translation_group`. `_emdash_menu_items.reference_id` +and `content_taxonomies.taxonomy_id` are remapped to store the referenced +row's translation_group, so a single association survives content +translations and is resolved against the active locale at runtime. + +- Runtime helpers (`getMenu`, `getTaxonomyTerms`, `getTerm`, `getEntryTerms`, + `getAllTermsForEntries`, …) accept an optional `{ locale }` and honour the + i18n fallback chain; when no locale is given they fall back to the + request context and `defaultLocale`, matching `getEmDashCollection` / + `getEmDashEntry`. +- REST API: GET endpoints accept `?locale=xx`; POST endpoints accept + `locale` and `translationOf` in their bodies. New endpoints: + `GET/POST /_emdash/api/menus/:name/translations` and + `GET/POST /_emdash/api/taxonomies/:name/terms/:slug/translations`. +- Creating a content translation now auto-copies the source's taxonomy + assignments (the pivot is locale-agnostic, so the copied rows apply to + the whole translation group). +- MCP: `taxonomy_list`, `taxonomy_list_terms`, `taxonomy_create_term`, + `menu_list`, `menu_get` accept `locale`. New tools: + `taxonomy_term_translations`, `menu_translations`. +- Admin: `TaxonomyManager` and `MenuList` surface a `LocaleSwitcher` when + multiple locales are configured and thread the active locale through + all API calls. `TaxonomyManager` exposes a "Translate" action per term + that creates the translation and switches to the new locale. + +No breaking changes for new installs or single-locale upgrades — defaults +are additive (locale defaults to `'en'` when omitted, reproducing pre-i18n +behaviour). + +> ⚠️ **Rolling back migration `036_i18n_menus_and_taxonomies` is blocked +> on multi-locale installs.** Dropping the `locale` column would collapse +> translated rows onto an ambiguous `(name, slug)` unique key, silently +> deleting content. The migration's `down()` now refuses to run when any +> row uses a non-default locale and prints the affected table in the +> error. If you need to revert, export translations first (or delete +> them), then re-run the rollback. Single-locale installs revert cleanly. diff --git a/docs/src/content/docs/guides/internationalization.mdx b/docs/src/content/docs/guides/internationalization.mdx index 34b3b09ef..03c16e4b8 100644 --- a/docs/src/content/docs/guides/internationalization.mdx +++ b/docs/src/content/docs/guides/internationalization.mdx @@ -95,6 +95,58 @@ Fallback only applies to single-entry queries. List queries return entries for t When a fallback is used, the response metadata includes `fallbackLocale` so your template can display a "this content is not yet translated" notice. +### Menus + +Menus are per-locale — the same `name` (e.g. `"primary"`) can exist in several +locales, all linked via a shared `translation_group`. Menu items resolve their +content references against the active locale's version of the referenced +content. + +```astro title="src/components/PrimaryNav.astro" +--- +import { getMenu } from "emdash"; + +const menu = await getMenu("primary", { locale: Astro.currentLocale }); +--- + + +``` + +Create translations of an existing menu from the admin's **Menus** list — the +items are cloned with `reference_id` intact (it stores the referenced content's +`translation_group`), so the new menu's links point at the right per-locale +content automatically. + +### Taxonomies (categories, tags) + +Terms are per-locale. Definitions (`_emdash_taxonomy_defs`) are also per-locale, +so `label` / `labelSingular` can be translated too. The pivot +`content_taxonomies.taxonomy_id` stores the term's `translation_group`, so a +single assignment spans every locale of the content. + +```astro +--- +import { getTaxonomyTerms, getEntryTerms } from "emdash"; + +const categories = await getTaxonomyTerms("category", { + locale: Astro.currentLocale, +}); +const terms = await getEntryTerms("posts", post.id, undefined, { + locale: Astro.currentLocale, +}); +--- +``` + +Translating a piece of content automatically inherits the source's term +assignments — you only need to translate the *terms themselves* once, and every +post that uses them resolves to the right locale at read time. + ### Collection listing Filter a collection by locale: diff --git a/e2e/fixtures/admin.ts b/e2e/fixtures/admin.ts index 18b1d4759..1da296532 100644 --- a/e2e/fixtures/admin.ts +++ b/e2e/fixtures/admin.ts @@ -504,7 +504,7 @@ export class AdminPage { async clickEditTranslation(locale: string): Promise { const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))"); const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`); - await localeRow.getByRole("link", { name: "Edit" }).click(); + await localeRow.getByRole("button", { name: "Edit" }).click(); } /** @@ -526,7 +526,7 @@ export class AdminPage { const sidebar = this.page.locator("div:has(> h3:text-is('Translations'))"); const localeRow = sidebar.locator(`div:has(> div > span.uppercase:text-is("${locale}"))`); return localeRow - .getByRole("link", { name: "Edit" }) + .getByRole("button", { name: "Edit" }) .isVisible({ timeout: 3000 }) .catch(() => false); } diff --git a/e2e/tests/menus.spec.ts b/e2e/tests/menus.spec.ts index 35c5118bc..d9f654dfc 100644 --- a/e2e/tests/menus.spec.ts +++ b/e2e/tests/menus.spec.ts @@ -55,8 +55,8 @@ test.describe("Menus", () => { // Create menu await admin.createMenu(menuName, menuLabel); - // Should redirect to menu editor - await expect(page).toHaveURL(new RegExp(`/menus/${menuName}$`)); + // Should redirect to menu editor (URL may include ?locale=... in i18n mode) + await expect(page).toHaveURL(new RegExp(`/menus/${menuName}(\\?|$)`)); // Should show the menu label await expect(page.locator("h1")).toContainText(menuLabel); diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index ff67028cf..685289741 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -24,7 +24,7 @@ import { ArrowsOutSimple, ArrowSquareOut, } from "@phosphor-icons/react"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import type { Editor } from "@tiptap/react"; import * as React from "react"; @@ -77,6 +77,7 @@ import { SaveButton } from "./SaveButton"; import { SeoImageField } from "./SeoImageField"; import { SeoPanel } from "./SeoPanel"; import { TaxonomySidebar } from "./TaxonomySidebar"; +import { TranslationsPanel } from "./TranslationsPanel.js"; // Editor role level (40) from @emdash-cms/auth const ROLE_EDITOR = 40; @@ -226,6 +227,7 @@ export function ContentEditor({ manifest, }: ContentEditorProps) { const { t } = useLingui(); + const navigate = useNavigate(); const [formData, setFormData] = React.useState>(item?.data || {}); const [slug, setSlug] = React.useState(item?.slug || ""); const [slugTouched, setSlugTouched] = React.useState(!!item?.slug); @@ -988,55 +990,19 @@ export function ContentEditor({ {/* Translations sidebar - shown when i18n is enabled */} {i18n && item && !isNew && (
-

{t`Translations`}

-
- {i18n.locales.map((locale) => { - const translation = translations?.find((tr) => tr.locale === locale); - const isCurrent = locale === item.locale; - return ( -
-
- {locale} - {locale === i18n.defaultLocale && ( - {t` (default)`} - )} - {isCurrent && ( - {t`current`} - )} -
- {translation && !isCurrent ? ( - - {t`Edit`} - - ) : !translation && onTranslate ? ( - - ) : null} -
- ); - })} -
+ + navigate({ + to: "/content/$collection/$id", + params: { collection, id: tr.id }, + }) + } + onCreate={onTranslate} + />
)} diff --git a/packages/admin/src/components/MenuEditor.tsx b/packages/admin/src/components/MenuEditor.tsx index 25d78a2dd..2b3cc5a97 100644 --- a/packages/admin/src/components/MenuEditor.tsx +++ b/packages/admin/src/components/MenuEditor.tsx @@ -16,7 +16,7 @@ import { File as FileIcon, } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useParams, useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import * as React from "react"; import { @@ -25,15 +25,22 @@ import { deleteMenuItem, updateMenuItem, reorderMenuItems, + fetchMenuTranslations, + createMenuTranslation, type MenuItem, } from "../lib/api"; +import { fetchManifest } from "../lib/api/client.js"; import { ArrowPrev } from "./ArrowIcons.js"; import { ContentPickerModal } from "./ContentPickerModal"; import { DialogError, getMutationError } from "./DialogError.js"; +import { useI18nConfig } from "./LocaleSwitcher.js"; +import { TranslationsPanel } from "./TranslationsPanel.js"; export function MenuEditor() { const { t } = useLingui(); const { name } = useParams({ from: "/_admin/menus/$name" }); + const search = useSearch({ from: "/_admin/menus/$name" }); + const routeLocale = search.locale; const navigate = useNavigate(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); @@ -44,12 +51,60 @@ export function MenuEditor() { const [addError, setAddError] = React.useState(null); const [editError, setEditError] = React.useState(null); + const { data: manifest } = useQuery({ + queryKey: ["manifest"], + queryFn: fetchManifest, + }); + const i18n = useI18nConfig(manifest); + const { data: menu, isLoading } = useQuery({ - queryKey: ["menu", name], - queryFn: () => fetchMenu(name), + queryKey: ["menu", name, routeLocale ?? null], + queryFn: () => fetchMenu(name, { locale: routeLocale }), staleTime: Infinity, }); + // The locale we lock mutations to: explicit URL param wins; else fall back + // to whatever the loaded menu row says (handles entry from the old /menus/$name + // URL without a locale query). + const menuLocale = routeLocale ?? menu?.locale; + + const { data: translationsData } = useQuery({ + queryKey: ["menu-translations", name, menuLocale ?? null], + queryFn: () => fetchMenuTranslations(name, { locale: menuLocale }), + enabled: !!menu && !!i18n && i18n.locales.length > 1, + }); + + const translateMutation = useMutation({ + mutationFn: (targetLocale: string) => + createMenuTranslation( + name, + { locale: targetLocale, label: menu?.label }, + { locale: menuLocale }, + ), + onSuccess: (translated) => { + void queryClient.invalidateQueries({ queryKey: ["menus"] }); + void queryClient.invalidateQueries({ queryKey: ["menu", name] }); + void queryClient.invalidateQueries({ queryKey: ["menu-translations", name] }); + toastManager.add({ + title: t`Translation created`, + description: t`Menu "${translated.label}" (${translated.locale.toUpperCase()}) created.`, + }); + // Switch the editor to the new locale so the user keeps editing. + void navigate({ + to: "/menus/$name", + params: { name }, + search: { locale: translated.locale }, + }); + }, + onError: (error: Error) => { + toastManager.add({ + title: t`Error`, + description: error.message, + type: "error", + }); + }, + }); + // Sync local items with fetched data React.useEffect(() => { if (menu?.items) { @@ -58,7 +113,8 @@ export function MenuEditor() { }, [menu]); const createMutation = useMutation({ - mutationFn: (input: Parameters[1]) => createMenuItem(name, input), + mutationFn: (input: Parameters[1]) => + createMenuItem(name, input, { locale: menuLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menu", name] }); setIsAddOpen(false); @@ -70,7 +126,7 @@ export function MenuEditor() { }); const deleteMutation = useMutation({ - mutationFn: (itemId: string) => deleteMenuItem(name, itemId), + mutationFn: (itemId: string) => deleteMenuItem(name, itemId, { locale: menuLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menu", name] }); toastManager.add({ @@ -94,7 +150,7 @@ export function MenuEditor() { }: { itemId: string; input: Parameters[2]; - }) => updateMenuItem(name, itemId, input), + }) => updateMenuItem(name, itemId, input, { locale: menuLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menu", name] }); setEditingItem(null); @@ -109,7 +165,8 @@ export function MenuEditor() { }); const reorderMutation = useMutation({ - mutationFn: (input: Parameters[1]) => reorderMenuItems(name, input), + mutationFn: (input: Parameters[1]) => + reorderMenuItems(name, input, { locale: menuLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menu", name] }); toastManager.add({ @@ -309,6 +366,32 @@ export function MenuEditor() { onSelect={handleAddContent} /> + {i18n && i18n.locales.length > 1 && menu ? ( +
+ ({ id: tr.id, locale: tr.locale })) ?? [ + { id: menu.id, locale: menu.locale }, + ] + } + onOpen={(tr) => + navigate({ + to: "/menus/$name", + params: { name }, + search: { locale: tr.locale }, + }) + } + onCreate={(target) => translateMutation.mutate(target)} + pendingLocale={ + translateMutation.isPending ? (translateMutation.variables ?? null) : null + } + /> +
+ ) : null} + {localItems.length === 0 ? (
diff --git a/packages/admin/src/components/MenuList.tsx b/packages/admin/src/components/MenuList.tsx index 1c69fe427..fa7ee90c5 100644 --- a/packages/admin/src/components/MenuList.tsx +++ b/packages/admin/src/components/MenuList.tsx @@ -13,8 +13,10 @@ import { Link, useNavigate } from "@tanstack/react-router"; import * as React from "react"; import { fetchMenus, createMenu, deleteMenu } from "../lib/api"; +import { fetchManifest } from "../lib/api/client.js"; import { ConfirmDialog } from "./ConfirmDialog.js"; import { DialogError, getMutationError } from "./DialogError.js"; +import { LocaleSwitcher, useI18nConfig } from "./LocaleSwitcher.js"; export function MenuList() { const { t } = useLingui(); @@ -25,9 +27,19 @@ export function MenuList() { const [deleteMenuName, setDeleteMenuName] = React.useState(null); const [createError, setCreateError] = 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]); + const { data: menus, isLoading } = useQuery({ - queryKey: ["menus"], - queryFn: fetchMenus, + queryKey: ["menus", activeLocale], + queryFn: () => fetchMenus({ locale: activeLocale }), }); const createMutation = useMutation({ @@ -39,7 +51,11 @@ export function MenuList() { title: t`Menu created`, description: t`Menu "${menu.label}" has been created.`, }); - void navigate({ to: "/menus/$name", params: { name: menu.name } }); + void navigate({ + to: "/menus/$name", + params: { name: menu.name }, + search: { locale: menu.locale }, + }); }, onError: (error: Error) => { setCreateError(error.message); @@ -47,7 +63,7 @@ export function MenuList() { }); const deleteMutation = useMutation({ - mutationFn: deleteMenu, + mutationFn: (name: string) => deleteMenu(name, { locale: activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["menus"] }); setDeleteMenuName(null); @@ -66,7 +82,7 @@ export function MenuList() { const name = typeof nameVal === "string" ? nameVal : ""; const labelVal = formData.get("label"); const label = typeof labelVal === "string" ? labelVal : ""; - createMutation.mutate({ name, label }); + createMutation.mutate({ name, label, locale: activeLocale }); }; if (isLoading) { @@ -79,11 +95,21 @@ export function MenuList() { return (
-
+

{t`Menus`}

{t`Manage navigation menus for your site`}

+
+ {i18n && activeLocale ? ( + + ) : null} +
{ @@ -167,9 +193,21 @@ export function MenuList() { key={menu.id} className="border rounded-lg p-6 flex items-center justify-between hover:bg-kumo-tint transition-colors" > - +
-

{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({
{term.count || 0}
+ {canTranslate && onTranslate ? ( + + ) : null} + )} + /> +
+
+ + +
+
+ + +
+ +
+ ); +} + /** * 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} @@ -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 ? ( + + ) : !translation && onCreate ? ( + + ) : 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(); - for (const row of countsResult) { - counts.set(row.taxonomy_id, row.count); - } + for (const row of countsResult) counts.set(row.taxonomy_id, row.count); const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({ id: row.id, @@ -118,12 +137,11 @@ export async function getTaxonomyTerms(taxonomyName: string): Promise ({ id: term.id, @@ -131,50 +149,71 @@ export async function getTaxonomyTerms(taxonomyName: string): Promise { +export async function getTerm( + taxonomyName: string, + slug: string, + options: TaxonomyQueryOptions = {}, +): Promise { const db = await getDb(); + const chain = resolveLocaleChain(options.locale); - const row = await db - .selectFrom("taxonomies") - .selectAll() - .where("name", "=", taxonomyName) - .where("slug", "=", slug) - .executeTakeFirst(); + let row: Awaited["executeTakeFirst"]>>; + const selectTerm = () => + db + .selectFrom("taxonomies") + .selectAll() + .where("name", "=", taxonomyName) + .where("slug", "=", slug); + + if (chain.length === 0) { + row = await selectTerm().orderBy("locale", "asc").executeTakeFirst(); + } else { + row = undefined; + for (const locale of chain) { + row = await selectTerm().where("locale", "=", locale).executeTakeFirst(); + if (row) break; + } + } if (!row) return null; - // Get entry count const countResult = await db .selectFrom("content_taxonomies") .select((eb) => eb.fn.count("entry_id").as("count")) - .where("taxonomy_id", "=", row.id) + .where("taxonomy_id", "=", row.translation_group ?? row.id) .executeTakeFirst(); - const count = countResult?.count ?? 0; - // Get children if hierarchical - const childRows = await db + let childrenQuery = db .selectFrom("taxonomies") .selectAll() .where("parent_id", "=", row.id) - .orderBy("label", "asc") - .execute(); + .orderBy("label", "asc"); + const termLocale = row.locale; + if (termLocale) childrenQuery = childrenQuery.where("locale", "=", termLocale); + const childRows = await childrenQuery.execute(); - const children = childRows.map((child) => ({ + const children = childRows.map((child) => ({ id: child.id, name: child.name, slug: child.slug, label: child.label, parentId: child.parent_id ?? undefined, children: [], + locale: child.locale, + translationGroup: child.translation_group, })); return { @@ -186,89 +225,75 @@ export async function getTerm(taxonomyName: string, slug: string): Promise { - return requestCached(`terms:${collection}:${entryId}:${taxonomyName ?? "*"}`, async () => { - const db = await getDb(); - - let query = db - .selectFrom("content_taxonomies") - .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id") - .selectAll("taxonomies") - .where("content_taxonomies.collection", "=", collection) - .where("content_taxonomies.entry_id", "=", entryId); + const locale = resolveLocale(options.locale); + return requestCached( + `terms:${collection}:${entryId}:${taxonomyName ?? "*"}:${locale ?? "*"}`, + async () => { + const db = await getDb(); - if (taxonomyName) { - query = query.where("taxonomies.name", "=", taxonomyName); - } + let query = db + .selectFrom("content_taxonomies") + .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id") + .selectAll("taxonomies") + .where("content_taxonomies.collection", "=", collection) + .where("content_taxonomies.entry_id", "=", entryId); - const rows = await query.execute(); + if (taxonomyName) query = query.where("taxonomies.name", "=", taxonomyName); + if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale); - return rows.map((row) => ({ - id: row.id, - name: row.name, - slug: row.slug, - label: row.label, - parentId: row.parent_id ?? undefined, - children: [], - })); - }); + const rows = await query.execute(); + return rows.map((row) => ({ + id: row.id, + name: row.name, + slug: row.slug, + label: row.label, + parentId: row.parent_id ?? undefined, + children: [], + locale: row.locale, + translationGroup: row.translation_group, + })); + }, + ); } /** - * Get terms for multiple entries in a single query (batched API) - * - * This is more efficient than calling getEntryTerms for each entry - * when you need terms for a list of entries. - * - * @param collection - The collection type (e.g., "posts") - * @param entryIds - Array of entry IDs - * @param taxonomyName - The taxonomy name (e.g., "categories") - * @returns Map from entry ID to array of terms + * Terms for multiple entries of one taxonomy, single query. */ export async function getTermsForEntries( collection: string, entryIds: string[], taxonomyName: string, + options: TaxonomyQueryOptions = {}, ): Promise> { const result = new Map(); - - // Initialize all entry IDs with empty arrays so callers can always - // expect the key to be present. const uniqueIds = [...new Set(entryIds)]; - for (const id of uniqueIds) { - result.set(id, []); - } - - if (uniqueIds.length === 0) { - return result; - } + for (const id of uniqueIds) result.set(id, []); + if (uniqueIds.length === 0) return result; const db = await getDb(); + const locale = resolveLocale(options.locale); - // Chunk the IN clause so we stay below D1's ~100 bound-parameter limit - // (and equivalent limits on other dialects). Matches getContentBylinesMany. - // - // Sites with no term assignments get back empty rows for one query — - // the previous "has any term assignments" probe spent a round-trip on - // every request to save that single query on empty sites, which is - // backwards. Pre-migration databases (content_taxonomies missing) fall - // through to the `isMissingTableError` catch and return empties. for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) { let rows; try { - rows = await db + let query = db .selectFrom("content_taxonomies") - .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id") + .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id") .select([ "content_taxonomies.entry_id", "taxonomies.id", @@ -276,18 +301,20 @@ export async function getTermsForEntries( "taxonomies.slug", "taxonomies.label", "taxonomies.parent_id", + "taxonomies.locale", + "taxonomies.translation_group", ]) .where("content_taxonomies.collection", "=", collection) .where("content_taxonomies.entry_id", "in", chunk) - .where("taxonomies.name", "=", taxonomyName) - .execute(); + .where("taxonomies.name", "=", taxonomyName); + if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale); + rows = await query.execute(); } catch (error) { if (isMissingTableError(error)) return result; throw error; } for (const row of rows) { - const entryId = row.entry_id; const term: TaxonomyTerm = { id: row.id, name: row.name, @@ -295,12 +322,11 @@ export async function getTermsForEntries( label: row.label, parentId: row.parent_id ?? undefined, children: [], + locale: row.locale, + translationGroup: row.translation_group, }; - - const terms = result.get(entryId); - if (terms) { - terms.push(term); - } + const terms = result.get(row.entry_id); + if (terms) terms.push(term); } } @@ -308,57 +334,29 @@ export async function getTermsForEntries( } /** - * Batch-fetch terms for multiple entries across ALL taxonomies in a single query. - * - * Returns a Map keyed by entry ID, where each value is a Record keyed by - * taxonomy name with the matching terms as an array. Used by - * getEmDashCollection to eagerly hydrate `entry.data.terms` and avoid - * the N+1 pattern that callers hit when they loop and call getEntryTerms. - * - * Pre-migration databases (content_taxonomies missing) return an empty - * Map — the join falls through to the `isMissingTableError` branch. + * Batch-fetch terms for multiple entries across ALL taxonomies in one query. + * Primes the request-cache for subsequent per-entry calls to `getEntryTerms`. */ export async function getAllTermsForEntries( collection: string, entryIds: string[], + options: TaxonomyQueryOptions = {}, ): Promise>> { const result = new Map>(); - - // Initialize unique entry IDs with empty objects so callers can always - // expect the key to be present. Deduping also reduces wasted bound - // parameters when a caller accidentally passes duplicates. const uniqueIds = [...new Set(entryIds)]; - for (const id of uniqueIds) { - result.set(id, {}); - } - - if (uniqueIds.length === 0) { - return result; - } + for (const id of uniqueIds) result.set(id, {}); + if (uniqueIds.length === 0) return result; const db = await getDb(); + const locale = resolveLocale(options.locale); + const applicableTaxonomyNames = await getCollectionTaxonomyNames(collection, { locale }); - // Look up which taxonomies apply to this collection. Used below to - // seed empty arrays for taxonomies the entry has no terms in — so - // callers (including the pre-populated getEntryTerms cache) get a - // deterministic `[]` back rather than a cache miss that triggers a DB - // round-trip just to confirm "no terms". - const applicableTaxonomyNames = await getCollectionTaxonomyNames(collection); - - // Chunk the IN clause to stay below D1's ~100 bound-parameter limit - // (and equivalent limits on other dialects). Matches getContentBylinesMany. - // - // Previously we did a separate "has any assignments" probe to skip the - // join on empty sites. That traded one query per request for a query - // saved only on empty sites — backwards. Now the join runs directly - // (returning zero rows cheaply) and pre-migration databases are caught - // by the `isMissingTableError` branch below. for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) { let rows; try { - rows = await db + let query = db .selectFrom("content_taxonomies") - .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id") + .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id") .select([ "content_taxonomies.entry_id", "taxonomies.id", @@ -366,15 +364,18 @@ export async function getAllTermsForEntries( "taxonomies.slug", "taxonomies.label", "taxonomies.parent_id", + "taxonomies.locale", + "taxonomies.translation_group", ]) .where("content_taxonomies.collection", "=", collection) .where("content_taxonomies.entry_id", "in", chunk) - .orderBy("taxonomies.label", "asc") - .execute(); + .orderBy("taxonomies.label", "asc"); + if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale); + rows = await query.execute(); } catch (error) { if (isMissingTableError(error)) { for (const id of uniqueIds) { - primeEntryTermsCache(collection, id, {}, applicableTaxonomyNames); + primeEntryTermsCache(collection, id, {}, applicableTaxonomyNames, locale); } return result; } @@ -382,7 +383,6 @@ export async function getAllTermsForEntries( } for (const row of rows) { - const entryId = row.entry_id; const term: TaxonomyTerm = { id: row.id, name: row.name, @@ -390,25 +390,19 @@ export async function getAllTermsForEntries( label: row.label, parentId: row.parent_id ?? undefined, children: [], + locale: row.locale, + translationGroup: row.translation_group, }; - - const byTaxonomy = result.get(entryId); + const byTaxonomy = result.get(row.entry_id); if (!byTaxonomy) continue; const existing = byTaxonomy[row.name]; - if (existing) { - existing.push(term); - } else { - byTaxonomy[row.name] = [term]; - } + if (existing) existing.push(term); + else byTaxonomy[row.name] = [term]; } } - // Prime the request-scoped cache so legacy callers of getEntryTerms - // (which still work per-entry) hit the in-memory cache instead of - // re-querying. This is what gives us the N+1 win in existing templates - // without requiring them to be rewritten. for (const [entryId, byTaxonomy] of result) { - primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames); + primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames, locale); } return result; @@ -420,9 +414,12 @@ export async function getAllTermsForEntries( * * Returns an empty list when taxonomies haven't been defined yet. */ -async function getCollectionTaxonomyNames(collection: string): Promise { +async function getCollectionTaxonomyNames( + collection: string, + options: TaxonomyQueryOptions, +): Promise { try { - const defs = await getTaxonomyDefs(); + const defs = await getTaxonomyDefs(options); return defs.filter((d) => d.collections.includes(collection)).map((d) => d.name); } catch (error) { if (isMissingTableError(error)) return []; @@ -447,44 +444,64 @@ function primeEntryTermsCache( entryId: string, byTaxonomy: Record, applicableTaxonomyNames: string[], + locale: string | undefined, ): void { - // Seed every applicable taxonomy with at least [] so - // getEntryTerms(collection, id, "tag") doesn't miss the cache when an - // entry has no tags. + const localeKey = locale ?? "*"; for (const name of applicableTaxonomyNames) { - setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, byTaxonomy[name] ?? []); + setRequestCacheEntry( + `terms:${collection}:${entryId}:${name}:${localeKey}`, + byTaxonomy[name] ?? [], + ); } - // Also seed individual names that show up in data but aren't listed - // as applicable (e.g. taxonomy reassigned to a different collection - // since the terms were written). for (const [name, terms] of Object.entries(byTaxonomy)) { - setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, terms); + setRequestCacheEntry(`terms:${collection}:${entryId}:${name}:${localeKey}`, terms); } - // Flattened `*` view — all terms across all taxonomies in one array. const allTerms = Object.values(byTaxonomy).flat(); - setRequestCacheEntry(`terms:${collection}:${entryId}:*`, allTerms); + setRequestCacheEntry(`terms:${collection}:${entryId}:*:${localeKey}`, allTerms); } /** - * Get entries by term (wraps getEmDashCollection) + * Get entries by term. Both the lookup (term slug in the active locale) and + * the content query respect the active locale. */ export async function getEntriesByTerm( collection: string, taxonomyName: string, termSlug: string, + options: TaxonomyQueryOptions = {}, ): Promise }>> { const { getEmDashCollection } = await import("../query.js"); - // Build options as the expected type — getEmDashCollection accepts - // a generic options object with `where` for filtering by taxonomy - const options: Record = { + const queryOptions: Record = { where: { [taxonomyName]: termSlug }, }; - const { entries } = await getEmDashCollection(collection, options); - + if (options.locale !== undefined) queryOptions.locale = options.locale; + const { entries } = await getEmDashCollection(collection, queryOptions); return entries; } +function rowToTaxonomyDef(row: { + id: string; + name: string; + label: string; + label_singular: string | null; + hierarchical: number; + collections: string | null; + locale: string; + translation_group: string | null; +}): 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, + }; +} + /** * Build tree structure from flat terms */ @@ -492,7 +509,6 @@ function buildTree(flatTerms: TaxonomyTermRow[], counts: Map): T const map = new Map(); const roots: TaxonomyTerm[] = []; - // First pass: create nodes for (const term of flatTerms) { map.set(term.id, { id: term.id, @@ -502,11 +518,12 @@ function buildTree(flatTerms: TaxonomyTermRow[], counts: Map): T parentId: term.parent_id ?? undefined, description: term.data ? JSON.parse(term.data).description : undefined, children: [], - count: counts.get(term.id) ?? 0, + count: counts.get(term.translation_group ?? term.id) ?? 0, + locale: term.locale, + translationGroup: term.translation_group, }); } - // Second pass: build tree for (const term of map.values()) { if (term.parentId && map.has(term.parentId)) { map.get(term.parentId)!.children.push(term); diff --git a/packages/core/src/taxonomies/types.ts b/packages/core/src/taxonomies/types.ts index b2a66adcf..5a3bcd925 100644 --- a/packages/core/src/taxonomies/types.ts +++ b/packages/core/src/taxonomies/types.ts @@ -12,6 +12,8 @@ export interface TaxonomyDef { labelSingular?: string; // 'Category', 'Tag' hierarchical: boolean; collections: string[]; // ['posts', 'pages'] + locale: string; // e.g. 'en', 'es' + translationGroup: string | null; // shared id across translations of the same def } /** @@ -26,6 +28,8 @@ export interface TaxonomyTerm { description?: string; children: TaxonomyTerm[]; // For tree structure count?: number; // Entry count + locale: string; + translationGroup: string | null; } /** @@ -38,6 +42,8 @@ export interface TaxonomyTermRow { label: string; parent_id: string | null; data: string | null; // JSON + locale: string; + translation_group: string | null; } /** @@ -48,6 +54,10 @@ export interface CreateTermInput { label: string; parentId?: string; description?: string; + locale?: string; + /** When set, links the new term into an existing translation_group (sourced + * from the term being translated). */ + translationOf?: string; } /** diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 1f6191f67..d79d8afcb 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -107,19 +107,22 @@ describe("Database Migrations (Integration)", () => { expect(migrations).toHaveLength(MIGRATION_COUNT); }); - it("should re-run migrations 034 and 035 when schema changes were partially applied", async () => { + it("should re-run trailing migrations when schema changes were partially applied", async () => { await db.destroy(); db = await setupTestDatabaseWithCollections(); - await db - .deleteFrom("_emdash_migrations") - .where("name", "in", ["034_published_at_index", "035_bounded_404_log"]) - .execute(); + // Kysely only re-runs trailing entries; include the latest migration. + const trailing = [ + "034_published_at_index", + "035_bounded_404_log", + "036_i18n_menus_and_taxonomies", + ]; + + await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); const { applied } = await runMigrations(db); - expect(applied).toContain("034_published_at_index"); - expect(applied).toContain("035_bounded_404_log"); + for (const name of trailing) expect(applied).toContain(name); const migrations = await db.selectFrom("_emdash_migrations").selectAll().execute(); expect(migrations).toHaveLength(MIGRATION_COUNT); diff --git a/packages/core/tests/unit/database/migrations/036_i18n_menus_and_taxonomies.test.ts b/packages/core/tests/unit/database/migrations/036_i18n_menus_and_taxonomies.test.ts new file mode 100644 index 000000000..5135d0fbc --- /dev/null +++ b/packages/core/tests/unit/database/migrations/036_i18n_menus_and_taxonomies.test.ts @@ -0,0 +1,450 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../../../../src/database/connection.js"; +import { down, up } from "../../../../src/database/migrations/036_i18n_menus_and_taxonomies.js"; +import type { Database } from "../../../../src/database/types.js"; +import { setI18nConfig } from "../../../../src/i18n/config.js"; + +/** + * Seed the four pre-i18n tables that migration 036 widens, plus the support + * tables it reads (`_emdash_collections`, `ec_posts`). Mirrors the schema + * shape immediately before this migration runs in production. + */ +async function seedPreMigrationSchema(db: Kysely): Promise { + await sql` + CREATE TABLE _emdash_menus ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) + `.execute(db); + + await sql` + CREATE TABLE _emdash_menu_items ( + id TEXT PRIMARY KEY, + menu_id TEXT NOT NULL, + parent_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + type TEXT NOT NULL, + reference_collection TEXT, + reference_id TEXT, + custom_url TEXT, + label TEXT NOT NULL, + title_attr TEXT, + target TEXT, + css_classes TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + `.execute(db); + + await sql` + CREATE TABLE taxonomies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL, + label TEXT NOT NULL, + parent_id TEXT, + data TEXT, + UNIQUE(name, slug), + FOREIGN KEY (parent_id) REFERENCES taxonomies(id) ON DELETE SET NULL + ) + `.execute(db); + + await sql` + CREATE TABLE _emdash_taxonomy_defs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + label_singular TEXT, + hierarchical INTEGER DEFAULT 0, + collections TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + `.execute(db); + + await sql` + CREATE TABLE content_taxonomies ( + collection TEXT NOT NULL, + entry_id TEXT NOT NULL, + taxonomy_id TEXT NOT NULL, + PRIMARY KEY (collection, entry_id, taxonomy_id), + FOREIGN KEY (taxonomy_id) REFERENCES taxonomies(id) ON DELETE CASCADE + ) + `.execute(db); + + await sql` + CREATE TABLE _emdash_collections ( + slug TEXT PRIMARY KEY + ) + `.execute(db); + + // translation_group is added to ec_* by migration 019; 036 reads it during remap. + await sql` + CREATE TABLE ec_posts ( + id TEXT PRIMARY KEY, + locale TEXT NOT NULL DEFAULT 'en', + translation_group TEXT + ) + `.execute(db); +} + +describe("036_i18n_menus_and_taxonomies migration", () => { + let db: Kysely; + + beforeEach(async () => { + db = createDatabase({ url: ":memory:" }); + await seedPreMigrationSchema(db); + }); + + afterEach(async () => { + await db.destroy(); + }); + + describe("up()", () => { + it("adds locale + translation_group to every widened table", async () => { + await up(db); + + const tables = await db.introspection.getTables(); + for (const name of [ + "_emdash_menus", + "_emdash_menu_items", + "taxonomies", + "_emdash_taxonomy_defs", + ]) { + const cols = tables.find((t) => t.name === name)?.columns.map((c) => c.name) ?? []; + expect(cols, `${name} should expose locale`).toContain("locale"); + expect(cols, `${name} should expose translation_group`).toContain("translation_group"); + } + }); + + it("creates locale + translation_group indexes on every widened table", async () => { + await up(db); + + const indexes = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%' + `.execute(db); + const names = new Set(indexes.rows.map((r) => r.name)); + + for (const table of [ + "_emdash_menus", + "_emdash_menu_items", + "taxonomies", + "_emdash_taxonomy_defs", + ]) { + expect(names, `missing locale index for ${table}`).toContain(`idx_${table}_locale`); + expect(names, `missing translation_group index for ${table}`).toContain( + `idx_${table}_translation_group`, + ); + } + }); + + it("backfills translation_group = id for pre-existing rows", async () => { + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute( + db, + ); + await sql`INSERT INTO _emdash_menu_items (id, menu_id, type, label) VALUES ('mi1', 'm1', 'custom', 'Home')`.execute( + db, + ); + await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'news', 'News')`.execute( + db, + ); + await sql`INSERT INTO _emdash_taxonomy_defs (id, name, label) VALUES ('d1', 'category', 'Categories')`.execute( + db, + ); + + await up(db); + + const checks: Array<{ table: string; id: string }> = [ + { table: "_emdash_menus", id: "m1" }, + { table: "_emdash_menu_items", id: "mi1" }, + { table: "taxonomies", id: "t1" }, + { table: "_emdash_taxonomy_defs", id: "d1" }, + ]; + for (const { table, id } of checks) { + const row = await sql<{ locale: string; translation_group: string | null }>` + SELECT locale, translation_group FROM ${sql.ref(table)} WHERE id = ${id} + `.execute(db); + expect(row.rows[0]?.locale, `${table} locale`).toBe("en"); + expect(row.rows[0]?.translation_group, `${table} translation_group`).toBe(id); + } + }); + + it("widens the menu unique key from (name) to (name, locale)", async () => { + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute( + db, + ); + await up(db); + + // Same name, different locale must now be allowed. + await sql` + INSERT INTO _emdash_menus (id, name, label, locale, translation_group) + VALUES ('m2', 'main', 'Principal', 'es', 'm1') + `.execute(db); + + // Same name and same locale must still conflict. + await expect( + sql` + INSERT INTO _emdash_menus (id, name, label, locale, translation_group) + VALUES ('m3', 'main', 'Other', 'es', 'm1') + `.execute(db), + ).rejects.toThrow(); + }); + + it("remaps content_taxonomies.taxonomy_id to taxonomies.translation_group", async () => { + // Two terms in different translation groups. + await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'news', 'News')`.execute( + db, + ); + await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t2', 'category', 'sports', 'Sports')`.execute( + db, + ); + await sql`INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id) VALUES ('posts', 'p1', 't1')`.execute( + db, + ); + await sql`INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id) VALUES ('posts', 'p1', 't2')`.execute( + db, + ); + + await up(db); + + const groups = await sql<{ taxonomy_id: string }>` + SELECT taxonomy_id FROM content_taxonomies WHERE entry_id = 'p1' ORDER BY taxonomy_id + `.execute(db); + // On a fresh install translation_group == id, so values look unchanged. + expect(groups.rows.map((r) => r.taxonomy_id).sort()).toEqual(["t1", "t2"]); + + // FK to taxonomies.id is gone: insert via group whose row id differs. + await sql` + INSERT INTO taxonomies (id, name, slug, label, locale, translation_group) + VALUES ('t1-es', 'category', 'noticias', 'Noticias', 'es', 't1') + `.execute(db); + await sql` + INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id) + VALUES ('posts', 'p2', 't1') + `.execute(db); + const orphan = await sql<{ count: number }>` + SELECT COUNT(*) AS count FROM content_taxonomies WHERE entry_id = 'p2' + `.execute(db); + expect(Number(orphan.rows[0]?.count ?? 0)).toBe(1); + }); + + it("remaps _emdash_menu_items.reference_id for content references", async () => { + await sql`INSERT INTO _emdash_collections (slug) VALUES ('posts')`.execute(db); + // Pre-existing post whose translation_group was minted by migration 019. + await sql`INSERT INTO ec_posts (id, locale, translation_group) VALUES ('post-1', 'en', 'group-1')`.execute( + db, + ); + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute( + db, + ); + await sql` + INSERT INTO _emdash_menu_items (id, menu_id, type, label, reference_collection, reference_id) + VALUES ('mi-content', 'm1', 'page', 'Home', 'posts', 'post-1') + `.execute(db); + + await up(db); + + const item = await sql<{ reference_id: string }>` + SELECT reference_id FROM _emdash_menu_items WHERE id = 'mi-content' + `.execute(db); + expect(item.rows[0]?.reference_id).toBe("group-1"); + }); + + it("remaps _emdash_menu_items.reference_id for taxonomy references", async () => { + await sql` + INSERT INTO taxonomies (id, name, slug, label) + VALUES ('term-1', 'category', 'news', 'News') + `.execute(db); + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute( + db, + ); + await sql` + INSERT INTO _emdash_menu_items (id, menu_id, type, label, reference_id) + VALUES ('mi-tax', 'm1', 'taxonomy', 'News', 'term-1') + `.execute(db); + + await up(db); + + const item = await sql<{ reference_id: string }>` + SELECT reference_id FROM _emdash_menu_items WHERE id = 'mi-tax' + `.execute(db); + // On a fresh install the remap is a no-op (translation_group == id). + expect(item.rows[0]?.reference_id).toBe("term-1"); + }); + + it("leaves items with reference_collection NULL untouched (runtime fallback handles them)", async () => { + await sql`INSERT INTO _emdash_collections (slug) VALUES ('posts')`.execute(db); + await sql`INSERT INTO ec_posts (id, locale, translation_group) VALUES ('post-1', 'en', 'group-1')`.execute( + db, + ); + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute( + db, + ); + // Legacy item: type='post' with reference_collection NULL. We can't + // migrate without guessing the collection slug, so the value is left + // as the original row id — runtime fallback resolves it directly. + await sql` + INSERT INTO _emdash_menu_items (id, menu_id, type, label, reference_collection, reference_id) + VALUES ('mi-legacy', 'm1', 'post', 'Post', NULL, 'post-1') + `.execute(db); + + await up(db); + + const item = await sql<{ reference_id: string }>` + SELECT reference_id FROM _emdash_menu_items WHERE id = 'mi-legacy' + `.execute(db); + expect(item.rows[0]?.reference_id).toBe("post-1"); + }); + + it("leaves menu items with non-resolving references untouched", async () => { + await sql`INSERT INTO _emdash_collections (slug) VALUES ('posts')`.execute(db); + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute( + db, + ); + // reference_id points at a post that doesn't exist — should stay as-is. + await sql` + INSERT INTO _emdash_menu_items (id, menu_id, type, label, reference_collection, reference_id) + VALUES ('mi-orphan', 'm1', 'page', 'Ghost', 'posts', 'post-missing') + `.execute(db); + + await up(db); + + const item = await sql<{ reference_id: string | null }>` + SELECT reference_id FROM _emdash_menu_items WHERE id = 'mi-orphan' + `.execute(db); + expect(item.rows[0]?.reference_id).toBe("post-missing"); + }); + }); + + describe("down()", () => { + it("reverts cleanly on a single-locale install", async () => { + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute( + db, + ); + await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'news', 'News')`.execute( + db, + ); + await up(db); + + await down(db); + + const tables = await db.introspection.getTables(); + for (const name of [ + "_emdash_menus", + "_emdash_menu_items", + "taxonomies", + "_emdash_taxonomy_defs", + ]) { + const cols = tables.find((t) => t.name === name)?.columns.map((c) => c.name) ?? []; + expect(cols, `${name} should not retain locale after rollback`).not.toContain("locale"); + expect(cols, `${name} should not retain translation_group after rollback`).not.toContain( + "translation_group", + ); + } + + const indexes = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%_locale' + `.execute(db); + expect(indexes.rows).toHaveLength(0); + + // Original rows survived the rollback. + const menu = await sql<{ name: string }>` + SELECT name FROM _emdash_menus WHERE id = 'm1' + `.execute(db); + expect(menu.rows[0]?.name).toBe("main"); + }); + + it("refuses to rollback when non-default-locale rows exist", async () => { + await up(db); + await sql` + INSERT INTO _emdash_menus (id, name, label, locale, translation_group) + VALUES ('m-fr', 'main', 'Principal', 'fr', 'm-fr') + `.execute(db); + + await expect(down(db)).rejects.toThrow(/non-default locale/i); + + // Assertion fired before any destructive work — schema still post-up. + const cols = (await db.introspection.getTables()) + .find((t) => t.name === "_emdash_menus") + ?.columns.map((c) => c.name); + expect(cols).toContain("locale"); + }); + + it("names the offending table in the rollback error", async () => { + await up(db); + await sql` + INSERT INTO taxonomies (id, name, slug, label, locale, translation_group) + VALUES ('t-es', 'category', 'noticias', 'Noticias', 'es', 't-es') + `.execute(db); + + await expect(down(db)).rejects.toThrow(/taxonomies/); + }); + }); + + describe("with non-default locale (defaultLocale='es')", () => { + beforeEach(() => { + setI18nConfig({ defaultLocale: "es", locales: ["es", "en"] }); + }); + + afterEach(() => { + setI18nConfig(null); + }); + + it("backfills pre-existing rows with the configured defaultLocale", async () => { + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Principal')`.execute( + db, + ); + await sql`INSERT INTO _emdash_menu_items (id, menu_id, type, label) VALUES ('mi1', 'm1', 'custom', 'Inicio')`.execute( + db, + ); + await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'noticias', 'Noticias')`.execute( + db, + ); + await sql`INSERT INTO _emdash_taxonomy_defs (id, name, label) VALUES ('d1', 'category', 'Categorías')`.execute( + db, + ); + + await up(db); + + for (const table of [ + "_emdash_menus", + "_emdash_menu_items", + "taxonomies", + "_emdash_taxonomy_defs", + ]) { + const row = await sql<{ locale: string }>` + SELECT locale FROM ${sql.ref(table)} LIMIT 1 + `.execute(db); + expect(row.rows[0]?.locale, `${table} should backfill with 'es'`).toBe("es"); + } + }); + + it("rolls back cleanly when only defaultLocale rows exist", async () => { + await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Principal')`.execute( + db, + ); + await up(db); + + await expect(down(db)).resolves.not.toThrow(); + + const cols = (await db.introspection.getTables()) + .find((t) => t.name === "_emdash_menus") + ?.columns.map((c) => c.name); + expect(cols).not.toContain("locale"); + }); + + it("blocks rollback when rows use a locale other than the configured default", async () => { + await up(db); + await sql` + INSERT INTO _emdash_menus (id, name, label, locale, translation_group) + VALUES ('m-en', 'main', 'Main', 'en', 'm-en') + `.execute(db); + + await expect(down(db)).rejects.toThrow(/defaultLocale="es"/); + }); + }); +}); diff --git a/packages/core/tests/unit/menus/menus.test.ts b/packages/core/tests/unit/menus/menus.test.ts index c3af19c92..b83b463e7 100644 --- a/packages/core/tests/unit/menus/menus.test.ts +++ b/packages/core/tests/unit/menus/menus.test.ts @@ -622,4 +622,55 @@ describe("Navigation Menus", () => { expect(items[0]?.menu_id).toBe(otherMenuId); }); }); + + describe("handleMenuCreate (translationOf)", () => { + it("clones items inheriting the source's translation_group", async () => { + const { handleMenuCreate } = await import("../../../src/api/handlers/menus.js"); + + const sourceId = ulid(); + await db + .insertInto("_emdash_menus") + .values({ + id: sourceId, + name: "primary", + label: "Primary", + locale: "en", + translation_group: sourceId, + }) + .execute(); + const sourceItemId = ulid(); + await db + .insertInto("_emdash_menu_items") + .values({ + id: sourceItemId, + menu_id: sourceId, + sort_order: 0, + type: "custom", + custom_url: "/", + label: "Home", + locale: "en", + translation_group: sourceItemId, + }) + .execute(); + + const result = await handleMenuCreate(db, { + name: "primary", + label: "Principal", + locale: "es", + translationOf: sourceId, + }); + expect(result.success).toBe(true); + + const cloned = await db + .selectFrom("_emdash_menu_items") + .selectAll() + .where("locale", "=", "es") + .executeTakeFirstOrThrow(); + + // Cloned item inherits source's translation_group so EN/ES rows + // identify as the same logical nav entry across translations. + expect(cloned.id).not.toBe(sourceItemId); + expect(cloned.translation_group).toBe(sourceItemId); + }); + }); }); diff --git a/packages/core/tests/unit/seed/apply.test.ts b/packages/core/tests/unit/seed/apply.test.ts index 7a88c94ed..764d9e7ef 100644 --- a/packages/core/tests/unit/seed/apply.test.ts +++ b/packages/core/tests/unit/seed/apply.test.ts @@ -167,6 +167,7 @@ describe("applySeed", () => { expect(row).not.toBeNull(); expect(row?.label).toBe("Topics"); expect(row?.hierarchical).toBe(1); + expect(row?.translation_group).toBe(row?.id); }); it("should create flat taxonomy terms", async () => { @@ -304,6 +305,16 @@ describe("applySeed", () => { expect(menu).not.toBeNull(); expect(menu?.label).toBe("Main Navigation"); + expect(menu?.translation_group).toBe(menu?.id); + + const items = await db + .selectFrom("_emdash_menu_items") + .selectAll() + .where("menu_id", "=", menu?.id ?? "") + .execute(); + for (const item of items) { + expect(item.translation_group, `item ${item.label}`).toBe(item.id); + } }); it("should create nested menu items", async () => { @@ -1043,4 +1054,130 @@ describe("applySeed", () => { expect(result2.redirects.skipped).toBe(1); }); }); + + describe("i18n round-trip", () => { + it("imports menu translations sharing one translation_group", async () => { + const seed: SeedFile = { + version: "1", + menus: [ + { + id: "menu:primary:en", + name: "primary", + label: "Primary", + locale: "en", + items: [{ type: "custom", label: "Home", url: "/" }], + }, + { + id: "menu:primary:es", + name: "primary", + label: "Principal", + locale: "es", + translationOf: "menu:primary:en", + items: [{ type: "custom", label: "Inicio", url: "/" }], + }, + ], + }; + + await applySeed(db, seed); + + const rows = await db + .selectFrom("_emdash_menus") + .selectAll() + .where("name", "=", "primary") + .orderBy("locale", "asc") + .execute(); + + expect(rows).toHaveLength(2); + expect(rows[0]?.locale).toBe("en"); + expect(rows[1]?.locale).toBe("es"); + expect(rows[0]?.translation_group).toBe(rows[1]?.translation_group); + expect(rows[0]?.translation_group).toBe(rows[0]?.id); + }); + + it("imports taxonomy def translations sharing one translation_group", async () => { + const seed: SeedFile = { + version: "1", + taxonomies: [ + { + id: "tax:topics:en", + name: "topics", + label: "Topics", + hierarchical: false, + collections: ["posts"], + locale: "en", + }, + { + id: "tax:topics:es", + name: "topics", + label: "Temas", + hierarchical: false, + collections: ["posts"], + locale: "es", + translationOf: "tax:topics:en", + }, + ], + }; + + await applySeed(db, seed); + + const rows = await db + .selectFrom("_emdash_taxonomy_defs") + .selectAll() + .where("name", "=", "topics") + .orderBy("locale", "asc") + .execute(); + + expect(rows).toHaveLength(2); + expect(rows[0]?.translation_group).toBe(rows[1]?.translation_group); + }); + + it("imports term translations sharing one translation_group", async () => { + const seed: SeedFile = { + version: "1", + taxonomies: [ + { + id: "tax:topics:en", + name: "topics", + label: "Topics", + hierarchical: false, + collections: ["posts"], + locale: "en", + terms: [{ id: "term:topics:tech:en", slug: "tech", label: "Tech", locale: "en" }], + }, + { + id: "tax:topics:es", + name: "topics", + label: "Temas", + hierarchical: false, + collections: ["posts"], + locale: "es", + translationOf: "tax:topics:en", + terms: [ + { + id: "term:topics:tech:es", + slug: "tecnologia", + label: "Tecnología", + locale: "es", + translationOf: "term:topics:tech:en", + }, + ], + }, + ], + }; + + await applySeed(db, seed); + + const terms = await db + .selectFrom("taxonomies") + .selectAll() + .where("name", "=", "topics") + .orderBy("locale", "asc") + .execute(); + + expect(terms).toHaveLength(2); + expect(terms[0]?.slug).toBe("tech"); + expect(terms[1]?.slug).toBe("tecnologia"); + expect(terms[0]?.translation_group).toBe(terms[1]?.translation_group); + }); + }); });