Skip to content

Commit 514d32d

Browse files
authored
fix(seed): menu item i18n (emdash-cms#943)
* fix(seed): round-trip menu item translation_group across export and apply * fix(seed): enable full i18n round-trip for menu and taxonomy seeds by adding ID, locale, and translation tracking.
1 parent 1a47584 commit 514d32d

6 files changed

Lines changed: 431 additions & 11 deletions

File tree

.changeset/seed-menu-item-i18n.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes seed menu items losing their `translation_group` across export/apply by adding optional `id`, `locale`, and `translationOf` fields to `SeedMenuItem`. The export emits stable seed IDs and `translationOf` references; the apply resolves them to the anchor's `translation_group`, matching the existing pattern for content entries, taxonomies, and terms.

packages/core/src/cli/commands/export-seed.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
SeedWidget,
3333
SeedContentEntry,
3434
} from "../../seed/types.js";
35+
import { slugify } from "../../utils/slugify.js";
3536

3637
const SETTINGS_PREFIX = "site:";
3738

@@ -101,7 +102,7 @@ export const exportSeedCommand = defineCommand({
101102
/**
102103
* Export database to seed file format
103104
*/
104-
async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
105+
export async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
105106
const seed: SeedFile = {
106107
$schema: "https://emdashcms.com/seed.schema.json",
107108
version: "1",
@@ -317,6 +318,9 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
317318
const result: SeedMenu[] = [];
318319
// translation_group -> seed-local id of the anchor menu in that group.
319320
const groupToSeedId = new Map<string, string>();
321+
// Shared across menus: translated items reference anchor items in sibling menus.
322+
const itemGroupToSeedId = new Map<string, string>();
323+
const usedItemSeedIds = new Set<string>();
320324

321325
for (const menu of menus) {
322326
const seedId =
@@ -329,7 +333,13 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
329333
.orderBy("sort_order", "asc")
330334
.execute();
331335

332-
const seedItems = buildMenuItemTree(items);
336+
const seedItems = buildMenuItemTree(items, {
337+
i18nEnabled,
338+
menuName: menu.name,
339+
menuLocale: menu.locale ?? null,
340+
itemGroupToSeedId,
341+
usedItemSeedIds,
342+
});
333343

334344
const seedMenu: SeedMenu = {
335345
id: seedId,
@@ -376,7 +386,17 @@ function buildMenuItemTree(
376386
target: string | null;
377387
title_attr: string | null;
378388
css_classes: string | null;
389+
locale?: string | null;
390+
translation_group?: string | null;
379391
}>,
392+
i18nCtx: {
393+
i18nEnabled: boolean;
394+
menuName: string;
395+
menuLocale: string | null;
396+
// translation_group -> seed-local id of the anchor item in that group.
397+
itemGroupToSeedId: Map<string, string>;
398+
usedItemSeedIds: Set<string>;
399+
},
380400
): SeedMenuItem[] {
381401
// Build parent -> children map
382402
const childMap = new Map<string | null, typeof items>();
@@ -389,10 +409,28 @@ function buildMenuItemTree(
389409
childMap.get(parentId)!.push(item);
390410
}
391411

412+
function makeSeedId(item: (typeof items)[number]): string {
413+
const base = slugify(item.label || "") || item.id;
414+
const locale = i18nCtx.i18nEnabled ? (item.locale ?? i18nCtx.menuLocale) : null;
415+
const candidate = locale
416+
? `item:${i18nCtx.menuName}:${base}:${locale}`
417+
: `item:${i18nCtx.menuName}:${base}`;
418+
if (!i18nCtx.usedItemSeedIds.has(candidate)) {
419+
i18nCtx.usedItemSeedIds.add(candidate);
420+
return candidate;
421+
}
422+
// Collision fallback: append DB id to disambiguate duplicate labels.
423+
const fallback = locale
424+
? `item:${i18nCtx.menuName}:${base}:${item.id}:${locale}`
425+
: `item:${i18nCtx.menuName}:${base}:${item.id}`;
426+
i18nCtx.usedItemSeedIds.add(fallback);
427+
return fallback;
428+
}
429+
392430
// Recursively build tree
393431
function buildLevel(parentId: string | null): SeedMenuItem[] {
394432
const children = childMap.get(parentId) || [];
395-
return children.map((item) => {
433+
const result = children.map((item) => {
396434
const seedItem: SeedMenuItem = {
397435
type: item.type,
398436
label: item.label || undefined,
@@ -415,6 +453,18 @@ function buildMenuItemTree(
415453
seedItem.cssClasses = item.css_classes;
416454
}
417455

456+
if (i18nCtx.i18nEnabled) {
457+
const itemLocale = item.locale ?? i18nCtx.menuLocale;
458+
const seedId = makeSeedId(item);
459+
seedItem.id = seedId;
460+
if (itemLocale) seedItem.locale = itemLocale;
461+
if (item.translation_group) {
462+
const anchor = i18nCtx.itemGroupToSeedId.get(item.translation_group);
463+
if (anchor && anchor !== seedId) seedItem.translationOf = anchor;
464+
else if (!anchor) i18nCtx.itemGroupToSeedId.set(item.translation_group, seedId);
465+
}
466+
}
467+
418468
// Add children
419469
const itemChildren = buildLevel(item.id);
420470
if (itemChildren.length > 0) {
@@ -423,6 +473,10 @@ function buildMenuItemTree(
423473

424474
return seedItem;
425475
});
476+
477+
// Sibling order is preserved (maps to sort_order on import). Cross-menu
478+
// `translationOf` already resolves because exportMenus sorts anchors first.
479+
return result;
426480
}
427481

428482
return buildLevel(null);

packages/core/src/seed/apply.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,8 @@ export async function applySeed(
512512
if (seed.menus) {
513513
// seed-local id -> resolved info, used to wire `translationOf` refs.
514514
const menuSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
515+
// Shared across menus: translated items reference anchor items in sibling menus.
516+
const itemSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
515517
const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
516518

517519
for (const menu of seed.menus) {
@@ -569,6 +571,7 @@ export async function applySeed(
569571
null, // parent_id
570572
0, // sort_order
571573
seedIdMap,
574+
itemSeedIdMap,
572575
);
573576
result.menus.items += itemCount;
574577
}
@@ -920,11 +923,10 @@ async function applyContentTaxonomies(
920923
/**
921924
* Apply menu items recursively.
922925
*
923-
* Each item gets a fresh `translation_group` (= its own id). The seed format's
924-
* `SeedMenuItem` has no `id`/`translationOf` fields, so we can't express the
925-
* cross-locale "same nav entry" link here — items diverge across locales on
926-
* re-apply. Runtime navigation still resolves correctly because `reference_id`
927-
* already holds the content's translation_group.
926+
* When a `SeedMenuItem` carries `id`/`translationOf`, the import resolves the
927+
* source item's `translation_group` so cross-locale "same nav entry" links
928+
* survive export → apply. Items without `translationOf` get a fresh group
929+
* (= their own id).
928930
*/
929931
async function applyMenuItems(
930932
db: Kysely<Database>,
@@ -934,12 +936,14 @@ async function applyMenuItems(
934936
parentId: string | null,
935937
startOrder: number,
936938
seedIdMap: Map<string, string>,
939+
itemSeedIdMap: Map<string, { id: string; translationGroup: string }>,
937940
): Promise<number> {
938941
let count = 0;
939942
let order = startOrder;
940943

941944
for (const item of items) {
942945
const itemId = ulid();
946+
const itemLocale = item.locale ?? locale;
943947

944948
// Resolve reference if needed
945949
let referenceId: string | null = null;
@@ -955,6 +959,16 @@ async function applyMenuItems(
955959
// If not in map, the content might not exist yet (will be broken link)
956960
}
957961

962+
let translationGroup = itemId;
963+
if (item.translationOf) {
964+
const source = itemSeedIdMap.get(item.translationOf);
965+
if (source) translationGroup = source.translationGroup;
966+
else
967+
console.warn(
968+
`menu item "${item.label ?? item.url ?? item.ref ?? "(unlabeled)"}" (${itemLocale}): translationOf "${item.translationOf}" not found yet; minting a fresh group.`,
969+
);
970+
}
971+
958972
await db
959973
.insertInto("_emdash_menu_items")
960974
.values({
@@ -971,23 +985,26 @@ async function applyMenuItems(
971985
target: item.target ?? null,
972986
css_classes: item.cssClasses ?? null,
973987
created_at: new Date().toISOString(),
974-
locale,
975-
translation_group: itemId,
988+
locale: itemLocale,
989+
translation_group: translationGroup,
976990
})
977991
.execute();
978992

993+
if (item.id) itemSeedIdMap.set(item.id, { id: itemId, translationGroup });
994+
979995
count++;
980996
order++;
981997

982998
if (item.children && item.children.length > 0) {
983999
const childCount = await applyMenuItems(
9841000
db,
9851001
menuId,
986-
locale,
1002+
itemLocale,
9871003
item.children,
9881004
itemId,
9891005
0,
9901006
seedIdMap,
1007+
itemSeedIdMap,
9911008
);
9921009
count += childCount;
9931010
}

packages/core/src/seed/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ export interface SeedMenu {
134134
* Menu item in seed
135135
*/
136136
export interface SeedMenuItem {
137+
/** Optional seed-local id, e.g. "item:primary:home:en". */
138+
id?: string;
137139
type: string;
138140
label?: string;
139141
url?: string; // For custom type
@@ -142,6 +144,8 @@ export interface SeedMenuItem {
142144
target?: "_blank" | "_self";
143145
titleAttr?: string;
144146
cssClasses?: string;
147+
locale?: string;
148+
translationOf?: string;
145149
children?: SeedMenuItem[];
146150
}
147151

packages/core/tests/unit/seed/apply.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,109 @@ describe("applySeed", () => {
11311131
expect(rows[0]?.translation_group).toBe(rows[1]?.translation_group);
11321132
});
11331133

1134+
it("imports menu item translations sharing one translation_group", async () => {
1135+
const seed: SeedFile = {
1136+
version: "1",
1137+
menus: [
1138+
{
1139+
id: "menu:primary:en",
1140+
name: "primary",
1141+
label: "Primary",
1142+
locale: "en",
1143+
items: [
1144+
{ id: "item:primary:home:en", type: "custom", label: "Home", url: "/", locale: "en" },
1145+
{
1146+
id: "item:primary:about:en",
1147+
type: "custom",
1148+
label: "About",
1149+
url: "/about",
1150+
locale: "en",
1151+
},
1152+
],
1153+
},
1154+
{
1155+
id: "menu:primary:es",
1156+
name: "primary",
1157+
label: "Principal",
1158+
locale: "es",
1159+
translationOf: "menu:primary:en",
1160+
items: [
1161+
{
1162+
id: "item:primary:home:es",
1163+
type: "custom",
1164+
label: "Inicio",
1165+
url: "/",
1166+
locale: "es",
1167+
translationOf: "item:primary:home:en",
1168+
},
1169+
{
1170+
id: "item:primary:about:es",
1171+
type: "custom",
1172+
label: "Acerca",
1173+
url: "/about",
1174+
locale: "es",
1175+
translationOf: "item:primary:about:en",
1176+
},
1177+
],
1178+
},
1179+
],
1180+
};
1181+
1182+
await applySeed(db, seed);
1183+
1184+
const items = await db
1185+
.selectFrom("_emdash_menu_items")
1186+
.selectAll()
1187+
.orderBy(["label", "locale"])
1188+
.execute();
1189+
1190+
expect(items).toHaveLength(4);
1191+
1192+
const enHome = items.find((i) => i.label === "Home");
1193+
const esHome = items.find((i) => i.label === "Inicio");
1194+
const enAbout = items.find((i) => i.label === "About");
1195+
const esAbout = items.find((i) => i.label === "Acerca");
1196+
1197+
expect(enHome?.translation_group).toBe(esHome?.translation_group);
1198+
expect(enHome?.translation_group).toBe(enHome?.id);
1199+
expect(enAbout?.translation_group).toBe(esAbout?.translation_group);
1200+
expect(enAbout?.translation_group).not.toBe(enHome?.translation_group);
1201+
});
1202+
1203+
it("falls back to fresh group when item translationOf is missing", async () => {
1204+
const seed: SeedFile = {
1205+
version: "1",
1206+
menus: [
1207+
{
1208+
id: "menu:primary:es",
1209+
name: "primary",
1210+
label: "Principal",
1211+
locale: "es",
1212+
items: [
1213+
{
1214+
id: "item:primary:home:es",
1215+
type: "custom",
1216+
label: "Inicio",
1217+
url: "/",
1218+
locale: "es",
1219+
translationOf: "item:primary:home:en",
1220+
},
1221+
],
1222+
},
1223+
],
1224+
};
1225+
1226+
await applySeed(db, seed);
1227+
1228+
const item = await db
1229+
.selectFrom("_emdash_menu_items")
1230+
.selectAll()
1231+
.where("label", "=", "Inicio")
1232+
.executeTakeFirst();
1233+
1234+
expect(item?.translation_group).toBe(item?.id);
1235+
});
1236+
11341237
it("imports term translations sharing one translation_group", async () => {
11351238
const seed: SeedFile = {
11361239
version: "1",

0 commit comments

Comments
 (0)