Skip to content

feat: i18n menus and taxonomies#916

Merged
ascorbic merged 20 commits intoemdash-cms:mainfrom
Rimander:feat/i18n-menus-taxonomies
May 6, 2026
Merged

feat: i18n menus and taxonomies#916
ascorbic merged 20 commits intoemdash-cms:mainfrom
Rimander:feat/i18n-menus-taxonomies

Conversation

@Rimander
Copy link
Copy Markdown
Contributor

@Rimander Rimander commented May 5, 2026

What does this PR do?

Adds i18n support to menus and taxonomies (definitions and terms), mirroring the per-locale row model already in place for content (migration 019). 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.

Highlights:

  • New migration 036_i18n_menus_and_taxonomies (SQLite + Postgres paths). Idempotent column checks, validated identifiers, FK-aware data remap on content_taxonomies. down() refuses to run on multi-locale installs (would silently drop rows otherwise) and names the offending table in the error.
  • Runtime helpers (getMenu, getTaxonomyTerms, getTerm, getEntryTerms, getAllTermsForEntries, getTaxonomyDef) accept an optional { locale } and honour the i18n fallback chain via the new shared helper i18n/resolve.ts (resolveLocale / resolveLocaleChain). Single-locale sites keep their old query count — the chain walk is a 1-element loop when i18n is disabled.
  • REST API: GET endpoints accept ?locale=xx; POST accepts locale and translationOf in the body. Two new endpoints:
    • GET/POST /_emdash/api/menus/:name/translations
    • GET/POST /_emdash/api/taxonomies/:name/terms/:slug/translations
  • MCP: taxonomy_list, taxonomy_list_terms, taxonomy_create_term, menu_list, menu_get accept locale. Two new tools: taxonomy_term_translations, menu_translations.
  • Admin UI: TaxonomyManager and MenuList surface the existing LocaleSwitcher when multiple locales are configured. New shared <TranslationsPanel> component reused by ContentEditor, MenuEditor and TaxonomyManager so the affordance is visually consistent. MenuEditor reads ?locale= from the route's validateSearch, so navigation between locales is bookmarkable.
  • Seed export/import round-trip for multi-locale menus and taxonomies. pnpm export-seed now emits one entry per (name, locale) for menus, taxonomy defs and terms, with stable seed-local ids (menu:primary:en, tax:topics:en, term:topics:tech:en) and translationOf references between siblings, anchors emitted first so the import can resolve them in order. seed/apply.ts consumes those fields, threading locale and translationOf to the existing handleMenuCreate / TaxonomyRepository.create paths (which already supported them after this PR's earlier commits).

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (21 diagnostics, all pre-existing on main; this PR introduces zero new ones)
  • pnpm test passes (193 test files, 3119 tests, all green; includes the new migration tests and the seed regression test)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Extend i18n (locale + translation_group) to menus and taxonomies #669

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7 (1M context)
    Mostly used for code analysis (data-flow tracing, bug hunting) and partial code generation, all reviewed before commit

Screenshots / test output

List of menus with the translation component
image

Menu details in two languages
image

List of categories with a translation option
image

Test Files  193 passed (193)
     Tests  3119 passed (3119)

Manual test (demo, defaultLocale: "en", locales: ["en", "es"]):

  1. Setup wizard → seed → menu "primary" appears in the admin Menus list.
  2. Open /menus/primary?locale=en → click "Translate" on the ES row in the right-hand TranslationsPanel.
  3. Editor jumps to /menus/primary?locale=es — items are cloned, can be renamed independently.
  4. The TranslationsPanel now shows "Edit" on the EN row (not "Translate"), confirming both rows share the same translation_group.

Note: I reorganized the changes using a new fork

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: 8c2f374

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Scope check

This PR changes 4,231 lines across 42 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Rimander
Copy link
Copy Markdown
Contributor Author

Rimander commented May 5, 2026

I have read the CLA Document and I hereby sign the CLA

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Copy link
Copy Markdown
Contributor

@MA2153 MA2153 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @Rimander, great work!

Just wondering, how does the seed behave? Content entries have a locale property, but it appears menus and taxonomies do not. Was that intentionally left out for some reason?

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 5, 2026

/review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 5, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@916

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@916

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@916

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@916

emdash

npm i https://pkg.pr.new/emdash@916

create-emdash

npm i https://pkg.pr.new/create-emdash@916

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@916

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@916

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@916

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@916

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@916

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@916

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@916

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@916

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@916

commit: 8c2f374

@github-actions github-actions Bot added the query-count changed PR diff modifies query-count snapshot files label May 5, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Query-count snapshot changes

4 routes changed, total Δ +8 queries.

SQLite

Route Before After Δ
GET /posts/building-for-the-long-term (cold) 23 25 +2
GET /posts/building-for-the-long-term (warm) 23 25 +2

D1

Route Before After Δ
GET /posts/building-for-the-long-term (cold) 32 34 +2
GET /posts/building-for-the-long-term (warm) 22 24 +2

Comparing snapshot files between base and head. Updated automatically on each push.

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid migration design overall — the (name, locale) widening, translation_group remapping for content_taxonomies and _emdash_menu_items.reference_id, and the protective down() assertion on multi-locale installs are all good. Tests cover the migration paths well, the requestCached keys are properly scoped per-locale, and the new i18n/resolve.ts helper keeps fallback semantics consistent.

A few real issues, though, the most significant being:

The seed export/import claim doesn't actually work for menus and taxonomies

The PR description says:

pnpm export-seed now emits one entry per (name, locale) for menus, taxonomy defs and terms, with stable seed-local ids (menu:primary:en, tax:topics:en, term:topics:tech:en) and translationOf references between siblings

But SeedTaxonomy, SeedTaxonomyTerm, and SeedMenu in packages/core/src/seed/types.ts (not modified in this PR) have no id, locale, or translationOf fields — only SeedContentEntry does. As a consequence:

  1. Exporter (packages/core/src/cli/commands/export-seed.ts:214-296) iterates every row from _emdash_taxonomy_defs / _emdash_menus and emits { name, label, ... } with no locale tag. On a multi-locale site this produces multiple SeedTaxonomy entries with the same name and no way to distinguish them.
  2. Importer (apply.ts:227-228 and :478-482) checks existence by name only. The second per-locale row is either skipped (onConflict: skip) or overwrites the first (onConflict: update). The :es translation never gets created.
  3. Same problem for SeedTaxonomyTerm (slug-only findBySlug on import) and SeedMenu items (no locale routing).

Net effect: exporting a multi-locale site and re-applying the seed silently loses every non-default-locale menu and taxonomy. There is no test exercising the multi-locale seed round-trip — tests/unit/seed/apply.test.ts doesn't reference locale or translationOf for these tables, only for content (which was wired up correctly in a prior PR).

This is what the open review comment from @MA2153 is asking about. Either:

  • (a) Add id / locale / translationOf to the seed types, update the exporter to emit them, and update apply.ts to thread them through (the existing TaxonomyRepository.create and handleMenuCreate paths already accept those fields). Add a regression test.
  • (b) Drop the multi-locale seed claim from the PR description and changeset, and file a follow-up issue.

Other findings inline below.

Comment thread packages/core/src/seed/apply.ts Outdated
label_singular: taxonomy.labelSingular ?? null,
hierarchical: taxonomy.hierarchical ? 1 : 0,
collections: JSON.stringify(taxonomy.collections),
translation_group: defId,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Locale-unaware existence check above (apply.ts:227-228where("name", "=", taxonomy.name)). _emdash_taxonomy_defs is now UNIQUE(name, locale), so two seed entries with the same name but different locales (the export-seed claim from the PR description) would both match this single row and one would be skipped/overwritten. Should filter by locale once SeedTaxonomy has a locale field — see the broader summary comment.

Comment thread packages/core/src/seed/apply.ts Outdated
label: menu.label,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
translation_group: menuId,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same locale-unaware existence check above (apply.ts:478-482) — _emdash_menus is now UNIQUE(name, locale) and the seed type lacks a locale field, so multi-locale menus can't round-trip through this path. Also note applyMenuItems (line 855) sets translation_group: itemId on every newly-inserted item with no concept of "this item is the ES translation of menu_item X", so even with locale support added on the menu level, items would diverge across translations.

Comment thread packages/core/src/taxonomies/index.ts Outdated
options: TaxonomyQueryOptions = {},
): Promise<TaxonomyDef | null> {
const chain = resolveLocaleChain(options.locale);
const allDefs = peekRequestCache<TaxonomyDef[]>("taxonomy-defs:all");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale cache key — this peek will never hit. On main both this peekRequestCache call and the requestCached in getTaxonomyDefs used the literal string "taxonomy-defs:all". After this PR, getTaxonomyDefs writes under taxonomy-defs:${locale ?? "*"} (line 39), but this lookup still uses the old "taxonomy-defs:all" constant.

Result: the in-memory shortcut never fires, and every page that calls getTaxonomyDef on a hot site does an extra _emdash_taxonomy_defs query that the optimization was specifically there to avoid. Silent (correctness is unaffected), but it's a real per-request perf regression on every multi-call page.

Suggested change
const allDefs = peekRequestCache<TaxonomyDef[]>("taxonomy-defs:all");
const chain = resolveLocaleChain(options.locale);
const peekKey = `taxonomy-defs:${resolveLocale(options.locale) ?? "*"}`;
const allDefs = peekRequestCache<TaxonomyDef[]>(peekKey);

Comment thread packages/core/src/menus/index.ts Outdated
*
* 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 035 remapped all existing references); we join it against
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc nit: comment says "migration 035" but the actual migration introduced in this PR is 036_i18n_menus_and_taxonomies (035_bounded_404_log is unrelated). Same off-by-one in the legacy-fallback comment further down.

Suggested change
* (migration 035 remapped all existing references); we join it against
* (migration 036 remapped all existing references); we join it against

Comment thread packages/core/src/menus/index.ts Outdated
}
if (!row) {
// Legacy rows whose reference_id still points at an id directly
// (defensive — migration 035 normalised these, but a row inserted
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit — should reference migration 036, not 035.

Suggested change
// (defensive — migration 035 normalised these, but a row inserted
// (defensive — migration 036 normalised these, but a row inserted

const name = params.name!;

const denied = requirePerm(user, "menus:read");
if (denied) return denied;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing requireDb(emdash?.db) check — the term-translations route added in this PR has it (taxonomies/[name]/terms/[slug]/translations.ts:37-38), but this one goes straight from the permission check to using emdash.db. If the runtime isn't initialised the route will crash with an unhandled exception instead of returning a clean 500. Same issue in the POST handler below.

Suggested change
if (denied) return denied;
const dbErr = requireDb(emdash?.db);
if (dbErr) return dbErr;
const denied = requirePerm(user, "menus:read");
if (denied) return denied;

for (const table of tables) {
validateSystemIdent(table);
const result = await sql<{ count: number | string }>`
SELECT COUNT(*) AS count FROM ${sql.ref(table)} WHERE locale != 'en'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding 'en' as the "safe to drop" locale doesn't match the rest of the system: the user's defaultLocale may be e.g. 'es' or 'de'. A fresh single-locale install in the user's chosen locale would have all rows backfilled to 'en' by up() — fine — but any rows inserted post-migration in the user's actual default locale would be flagged here as "non-default" and block rollback.

In practice this only bites users who reconfigured defaultLocale away from 'en' and have a single-locale install. Probably acceptable — the error message tells them what to do — but worth either documenting or comparing against the column DEFAULT introspectively rather than the literal 'en'.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me. I have a non-English default locale. This is kind of a big deal if true. The review seem to downplay it...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration now resolves the locale via getI18nConfig()?.defaultLocale ?? "en" and threads it through every backfill, column DEFAULT, and the rollback guard. Added 3 regression tests with defaultLocale: "es".

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remap only fires when reference_collection = ${slug}, but historically menu items of type='page' / type='post' may have reference_collection IS NULL (the runtime falls back to ${type}s in menus/index.ts:192). For those rows reference_id keeps pointing at the original row id rather than the translation_group, and runtime resolution then falls into the defensive legacy branch (menus/index.ts:284-289).

Not a correctness bug — the legacy lookup catches it — but on a multi-locale site those menu items will resolve to the row with that exact id regardless of active locale, instead of following the translation_group to the locale-specific copy. Worth either also matching reference_collection IS NULL AND type IN ('page','post') here, or leaving a comment explaining the runtime fallback is the intended escape hatch.

target: item.target,
css_classes: item.css_classes,
...(input.locale !== undefined ? { locale: input.locale } : {}),
translation_group: item.translation_group ?? item.id,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

item.translation_group ?? item.id copies the source item's translation_group onto the new translation's items. That's actually a reasonable choice (cloned items in different locales then identify as "the same nav entry across translations") — but the comment two lines up says the opposite: "The items' own locale/translation_group get fresh values tied to the new menu."

One of the two needs updating. If source-shared groups are intended, the comment should say so. If fresh per-clone groups are intended, this should be translation_group: newId (or ulid()). Worth a test pinning down whichever you decide — at the moment a downstream caller can't tell from the docs which behaviour to rely on.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

Review submitted successfully. The review covers:

  1. Major (top-level summary): The seed export/import claim from the PR description doesn't actually work for menus and taxonomies — seed types lack id/locale/translationOf fields, the exporter and importer collapse multi-locale rows.

  2. Bug — performance regression (taxonomies/index.ts:62): Stale cache key "taxonomy-defs:all" will never hit after the per-locale rekeying.

  3. Doc bugs (menus/index.ts:172, 282): Comments reference "migration 035" but the actual migration is 036.

  4. Inconsistency (api/handlers/menus.ts:207): Comment claims fresh translation_groups, code copies the source's group.

  5. Robustness (menus/[name]/translations.ts:31): Missing requireDb guard, unlike the term-translations sibling route.

  6. Edge cases in the migration (036_*.ts:213, 305): Hardcoded 'en' in the down() assertion may falsely block rollback for users with non-'en' defaultLocale; the menu-item remap misses page/post items with NULL reference_collection.

  7. Two seed apply locale-unaware existence checks at apply.ts:260, 501.

github run


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, 'en', id FROM _emdash_menus
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also assumes 'en'

.addColumn("label", "text", (c) => c.notNull())
.addColumn("parent_id", "text")
.addColumn("data", "text")
.addColumn("locale", "text", (c) => c.notNull().defaultTo("en"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assume 'en' too

.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("en"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assumes 'en'

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, 'en', id
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assumes 'en'

): Promise<void> {
validateSystemIdent(table);
const ref = sql.ref(table);
await sql`ALTER TABLE ${ref} ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en'`.execute(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assumes 'en'

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 = 'en'),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assumes 'en'

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 5, 2026

/review

@Rimander
Copy link
Copy Markdown
Contributor Author

Rimander commented May 5, 2026

Pushed updates addressing the review. Tests pass locally.

  • Migration 036 now uses getI18nConfig()?.defaultLocale ?? "en" for backfills, column DEFAULTs and the rollback guard, with regression tests for non-en defaults.
  • Seed round-trips multi-locale menus and taxonomies (id / locale / translationOf on the seed types, (name, locale) existence checks, tests in seed/apply.test.ts).
  • Fixed taxonomies/index.ts cache key (GET /posts/... back to 23 queries).
  • Added requireDb guard on the menu translations route.
  • Cloned menu items inherit the source's translation_group, matching the pattern in content.ts.
  • E2E fixtures realigned with the current UI (Edit button selector, menu URL tolerates ?locale=...).

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took another pass after the i18n-config rework. The defaultLocale plumbing in the migration (and the new getDefaultLocale() helper threaded through column DEFAULTs and the rollback assertion) addresses the main concern from the previous reviews — nice job. The seed types now carry id / locale / translationOf for menus and taxonomies, the exporter emits per-locale entries with anchor-first ordering, and apply.ts resolves them through scoped maps. Cache key for taxonomy-defs is rekeyed correctly, both translations routes guard requireDb, comments reference migration 036, and the menu-items clone comment now matches the code.

One real bug left, plus two minor follow-ups inline.

Comment thread packages/core/src/api/handlers/menus.ts Outdated
}

// Duplicate guard: same (name, locale). Default 'en' matches the column DEFAULT.
const effectiveLocale = input.locale ?? "en";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded "en" here doesn't match the rest of the system. The migration's column DEFAULT now uses getI18nConfig()?.defaultLocale ?? "en", so on an install with defaultLocale: "es" (the case the previous review thread specifically covered) an existing menu row inserted without locale lives at locale = 'es'. This duplicate guard searches locale = 'en', finds nothing, and falls through to the insert. The DB UNIQUE constraint then fires and gets caught by the generic catch at the bottom of the function — so the user sees MENU_CREATE_ERROR ("Failed to create menu") instead of the clean CONFLICT with the helpful message a few lines down.

Not catastrophic (data isn't corrupted), but it's exactly the 'en'-assumption pattern the rest of the PR moved away from. Suggest threading the same helper through:

Suggested change
const effectiveLocale = input.locale ?? "en";
const effectiveLocale = input.locale ?? (getI18nConfig()?.defaultLocale ?? "en");

css_classes: item.cssClasses ?? null,
created_at: new Date().toISOString(),
locale,
translation_group: itemId,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up / follow-up: every imported menu item gets a fresh translation_group = itemId, so when an exported multi-locale site is re-applied the per-locale menu items no longer share a translation_group across locales (each item identifies as its own group). Runtime resolution still works because reference_id stores the content's translation_group, but the cross-locale "this is the same nav entry" link is lost on the round-trip.

SeedMenuItem has no id / translationOf fields today, so there's no way to express item-level translation links in the seed format yet. Worth either calling out explicitly in the PR description ("menu items don't round-trip their translation_group, only the menu does") or filing a follow-up issue — the PR description currently implies items round-trip too.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor robustness nit, not blocking: the remap predicate is WHERE reference_collection = ${slug}, but legacy _emdash_menu_items rows of type='page' / type='post' may have reference_collection IS NULL (the runtime falls back to ${type}s in menus/index.ts:198). For those rows reference_id keeps pointing at a row id rather than the translation_group, and resolveContentUrl then hits the defensive legacy branch (lines 291–299).

Functionally fine — the legacy branch catches it — but on a multi-locale site those items will lock to the row with that exact id regardless of active locale instead of following the translation_group. Worth either widening the predicate to also match (reference_collection IS NULL AND type IN ('page','post')) here, or adding a comment pointing to the runtime fallback as the intended escape hatch.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

Review submitted. Summary of what I posted:

  1. Top-level summary: Acknowledged that the prior review cycle's main concerns (defaultLocale plumbing, seed type fields, cache key rekeying, requireDb guards, migration 036 doc fixes, clone comment) are properly addressed.

  2. Real bug (packages/core/src/api/handlers/menus.ts:140): effectiveLocale = input.locale ?? "en" is the one remaining hardcoded 'en' that contradicts the rest of the migration's getI18nConfig()?.defaultLocale pattern. On an install with defaultLocale: "es", the duplicate guard never fires, falls through to the UNIQUE-constraint insert, and the user gets a generic MENU_CREATE_ERROR instead of the clean CONFLICT. Posted a suggestion block.

  3. Round-trip caveat (packages/core/src/seed/apply.ts:969): applyMenuItems always sets translation_group: itemId, so menu items diverge across locales on re-apply. Runtime navigation still works (because reference_id is the content's translation_group), but the cross-locale "same nav entry" link is lost. SeedMenuItem has no id/translationOf fields, so this can't be expressed in the seed format yet. Suggested either documenting the limit or filing a follow-up.

  4. Migration robustness nit (packages/core/src/database/migrations/036_i18n_menus_and_taxonomies.ts:222): Remap misses page/post items where reference_collection IS NULL (the runtime falls back to ${type}s). Defensive legacy branch in resolveContentUrl catches it, but those rows lock to a single locale.

github run

@Rimander
Copy link
Copy Markdown
Contributor Author

Rimander commented May 5, 2026

Pushed 5c10265 addressing the latest review:

  • handleMenuCreate now resolves effectiveLocale via input.locale ?? getI18nConfig()?.defaultLocale ?? "en", matching the migration's column DEFAULT — the CONFLICT guard now fires correctly on non-en installs.
  • applyMenuItems documents the seed-format limitation: SeedMenuItem has no id/translationOf, so menu items don't round-trip the cross-locale link (only menus and taxonomies do).
  • The reference_collection IS NULL remap nit is already documented in remapMenuItemRefs (b1ffa39) — pointing at the runtime fallback in menus/index.ts as the intended escape hatch.

Full CI is green, and I also ran the suite end-to-end in a Playwright Docker image locally: 3135 unit + 851 browser + 234/245 e2e. The two remaining e2e fails (invite-flow, setup-wizard) are flaky on long single-worker runs and pass on the CI shards.

Thanks for the thorough reviews!

Copy link
Copy Markdown
Contributor

@MA2153 MA2153 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

At first glance it looks like SeedMenuItem translation support could be implemented the same way as SeedTaxonomyTerm in the future.

Thank you for working on this!

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! Thank you

@ascorbic ascorbic merged commit 71f4e7d into emdash-cms:main May 6, 2026
29 checks passed
@emdashbot emdashbot Bot mentioned this pull request May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants