Skip to content

fix(taxonomies): respect active locale in admin surfaces - #2342

Open
fbartolitsch wants to merge 7 commits into
emdash-cms:mainfrom
fbartolitsch:codex/2224-taxonomy-locale-normalization
Open

fix(taxonomies): respect active locale in admin surfaces#2342
fbartolitsch wants to merge 7 commits into
emdash-cms:mainfrom
fbartolitsch:codex/2224-taxonomy-locale-normalization

Conversation

@fbartolitsch

@fbartolitsch fbartolitsch commented Aug 5, 2026

Copy link
Copy Markdown

What does this PR do?

Summary

  • Resolves localized taxonomy definitions once per logical taxonomy before rendering admin navigation and editor choices.
  • Preserves the active locale when navigating from the admin sidebar into taxonomy management.
  • Uses one shared fallback policy: exact active locale, then the configured default locale, then a deterministic definition.
  • Scopes visible taxonomy term counts and their cache entries to the resolved content locale.

Scope

  • Admin sidebar taxonomy navigation and content-editor taxonomy choices.
  • Runtime manifest taxonomy identity metadata (id, locale, and translationGroup).
  • Core taxonomy definition fallback and locale-scoped count queries.
  • Regression coverage for sidebar paths, editor choices, manifest metadata, partially translated sites, and term counts.
  • Patch changeset for @emdash-cms/admin and minor changeset for emdash.

Validation

  • pnpm format and pnpm format:check
  • pnpm build
  • pnpm typecheck
  • pnpm lint and pnpm lint:quick
  • Admin regression tests: 2 files, 27 tests passed
  • Core regression tests: 3 files, 27 tests passed
  • SQLite and D1 query-count generation: 20 route/phase entries each, all measured routes returned 200
  • Query-count totals unchanged; SQL snapshots only add the expected locale predicate
  • git diff --cached upstream/main --check

Risk and rollback

  • The resolver groups explicit translations by translationGroup; legacy definitions without that metadata fall back to their taxonomy name.
  • Incomplete taxonomy translations select the active locale, then the configured default, then the lexically first locale/id while retaining the requested content locale for term rows and counts.
  • Public taxonomy helpers now expose per-locale visible counts instead of totals combined across translations; the emdash changeset is therefore minor.
  • Count queries add a parameterized locale predicate and locale-aware cache key without increasing logged-out query counts.
  • No schema migration or data rewrite is included.
  • The branch contains a regular merge of upstream/main; no history rewrite or force-push was used.
  • Rollback is a mainline revert of the merge commit.

Security/privacy impact

No new external calls, credentials, permissions, or personal-data processing are introduced.

Fixes #2224
Fixes #2338

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
  • pnpm test passes (or targeted tests for my change)
  • 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). No new user-visible strings or messages.po changes are included.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: N/A — this is a bug fix.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: OpenAI Codex (GPT-5)

Screenshots / test output

Targeted automated tests:

Admin: 2 files, 27 tests passed
Core: 3 files, 27 tests passed

Group translated definitions by logical identity before rendering admin navigation and editor choices. Scope visible term counts and cache entries to the resolved content locale.
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a19cfe4

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

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/admin Minor
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@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 github-actions Bot added the review/needs-review No maintainer or bot review yet label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

@fbartolitsch

Copy link
Copy Markdown
Author

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

github-actions Bot added a commit that referenced this pull request Aug 5, 2026

@emdashbot emdashbot Bot left a comment

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 is a focused bug fix with the right shape: normalize localized taxonomy definitions to one logical row per locale, then use that resolved locale in admin labels, editor choices, manifest metadata, and visible term counts. I read the full diff, the changed source files, and the relevant route/handler call sites.

Headline: the approach is sound, but the sidebar navigation only respects the active locale for the label — the taxonomy-management link it produces drops the ?locale= parameter, so clicking a DE item lands on the default-locale page. That undermines the stated goal and should be fixed before merge.

I also found a smaller edge where the API term-list/term-get handlers still scope visible counts off the lowest-locale taxonomy definition rather than the active-locale one. That is safe when translations share collections, but it is inconsistent with the public taxonomies/index.ts helpers and worth aligning.

No SQL-injection issues: the dynamic locale predicate is interpolated as a Kysely parameter. No new logged-out round-trips are introduced; the count query just gains a locale predicate and a locale-aware cache key. Tests cover the new resolver, editor filtering, manifest identity, and locale-scoped counts.


Findings

  • [needs fixing] packages/admin/src/components/Sidebar.tsx:254

    The sidebar resolves the active-locale taxonomy label using routeLocale, but the generated taxonomy nav items omit the locale, so the link navigates to e.g. /taxonomies/course without ?locale=de. Clicking a German taxonomy in the sidebar will open the taxonomy-management route in the configured default/locale-less fallback, which partially defeats the PR's goal of respecting the active content locale on admin surfaces.

    Extend NavItem to carry optional query params and append them in resolveItemPath so taxonomy links preserve the active locale:

    // in the manageItems construction
    ...getSidebarTaxonomies(manifest.taxonomies, routeLocale, manifest.i18n?.defaultLocale).map(
    	(tax) => ({
    		to: "/taxonomies/$taxonomy" as const,
    		label: tax.label,
    		icon: getTaxonomyNavIcon(tax.name),
    		params: { taxonomy: tax.name },
    		search: routeLocale ? { locale: routeLocale } : undefined,
    		minRole: ROLE_EDITOR,
    	}),
    ),
    
    // interface NavItem { ... search?: Record<string, string>; ... }
    
    function resolveItemPath(item: NavItem): string {
    	let path = item.to;
    	if (item.params) {
    		for (const [key, value] of Object.entries(item.params)) {
    			path = path.replace(`$${key}`, value);
    		}
    	}
    	if (item.search && Object.keys(item.search).length > 0) {
    		const q = new URLSearchParams(item.search).toString();
    		path += `?${q}`;
    	}
    	return path;
    }
  • [suggestion] packages/core/src/api/handlers/taxonomies.ts:393

    handleTermList resolves the taxonomy with requireTaxonomyDef(db, taxonomyName) (no locale), which returns the lowest-locale row, and then scopes visible counts to defCollections(lookup.def). If a taxonomy's translations declare different collections, the active-locale term list will count the wrong collections. The public helper getTaxonomyDef(name, options) already resolves the active-locale definition.

    Suggestion: pass the resolved locale to requireTaxonomyDef here (and in handleTermGet at line 659) so counts use the same definition the user is actually viewing:

    const lookup = await requireTaxonomyDef(db, taxonomyName, locale);

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/needs-review No maintainer or bot review yet labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The direction is right and most of this is well built — the client-side resolveTaxonomyDefinitions fallback chain is exactly the right shape, the cache keys correctly grow a locale component, and the dialect-parameterized test extensions are thorough. Two things before merge: a server-side regression for partially-translated sites (inline below), and the changeset undersells a public-site behavior change.

On the changeset: the per-locale counts flow into getTaxonomyTerms, which is the public taxonomy widget API — multi-locale sites will see their live term counts change (from cross-locale totals to per-locale). I agree the new counts are the correct ones — the old totals didn't match what a visitor clicking through actually sees — but that's an observable public-site change dressed in admin-fix language. Say it plainly in the changeset, and I'd bump emdash to minor.

const lookup = await requireTaxonomyDef(db, taxonomyName);
// use the active definition for its collection scope.
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
const lookup = await requireTaxonomyDef(db, taxonomyName, locale);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a hard where locale = match with no fallback (requireTaxonomyDef filters exactly when a locale is passed), so a site with DE content but taxonomy defs only defined in EN — the normal state of any partially-translated site — now gets NOT_FOUND from the term list for DE entries, where it worked before this PR. handleTermGet has the milder version of the same problem: the lenient lookup fails, defCollections becomes [], and the count silently reads 0.

The client half of this PR already handles this correctly (resolveTaxonomyDefinitions: active locale → default → deterministic first). The server needs the same leniency — prefer the exact-locale def, fall back to any row of the group — since the def is only used for its collections scope here. Something like ordering by locale = ? DESC and taking the first row instead of filtering.

Worth a test pinning it: term list for a locale that has terms and content but no translated def row.

@pkg-pr-new

pkg-pr-new Bot commented Aug 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2342

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2342

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2342

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2342

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2342

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2342

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2342

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 05e575e

@ascorbic

ascorbic commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

The query snapshots can be resolved by accepting main's version: they'll be updated once you push to CI

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Aug 12, 2026

@emdashbot emdashbot Bot left a comment

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.

Re-review of PR #2342. The approach is sound: resolve localized taxonomy definitions to one logical row per locale, then use that resolved locale for sidebar labels/links, editor choices, manifest identity metadata, and count queries. The two findings from the previous review are both addressed — sidebar taxonomy links now carry ?locale= and the admin term-list/term-get handlers resolve the taxonomy definition for the active locale before counting.

I read the full diff, the changed files, and the relevant tests. Headline conclusion: the implementation is close, but the sidebar's active-state matching was not updated for links that now include query params, so taxonomy items in a non-default locale are never highlighted as active. That's a regression against the PR's goal and should be fixed before merge.

Everything else looks good: SQL is parameterized, count queries only add a locale predicate without extra round trips, manifest identity metadata includes id/locale/translationGroup, editor choices resolve to the entry locale, tests reproduce the new resolver/filtering/count behavior, and changesets cover both packages.


Findings

  • [needs fixing] packages/admin/src/components/Sidebar.tsx:212-216

    Taxonomy nav items now include ?locale=... in itemPath via resolveItemPath, but isItemActive compares currentPath (location.pathname, no query string) against the full itemPath. That means /taxonomies/course?locale=de never matches /taxonomies/course, so the active taxonomy is not highlighted when a non-default locale is selected — undermining the PR's goal of respecting the active locale on admin surfaces.

    Strip the query string before matching, and add a regression test that calls isItemActive with a query-param-bearing path.

    function isItemActive(itemPath: string, currentPath: string): boolean {
    	if (itemPath === "/") return currentPath === "/";
    	const path = itemPath.split("?")[0];
    	return currentPath === path || currentPath.startsWith(`${path}/`);
    }
    

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 12, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Aug 12, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Aug 13, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

@ascorbic
ascorbic enabled auto-merge (squash) August 13, 2026 13:25
@github-actions github-actions Bot added review/approved Approved; no new commits since needs-rebase and removed review/needs-rereview Author pushed changes since the last review labels Aug 13, 2026
auto-merge was automatically disabled August 14, 2026 05:08

Head branch was pushed to by a user without write access

@fbartolitsch

Copy link
Copy Markdown
Author

Updated this branch with the upstream merge and adjusted the locale fallback tests. Focused taxonomy tests and lint pass locally. Full pnpm typecheck currently fails on main because of the unrelated missing restampEntryPivot method, tracked in #2466. We will rerun full validation once that fix is merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants