fix(core): preserve locale casing in API locale filter (#1551)#2174
Conversation
The `localeCode` validator lowercased the `?locale=` filter and create-body locale, but config `locales`/`defaultLocale`, the stored `locale` column, and the public query path all keep the raw BCP-47 casing. As a result the content and search APIs returned zero rows for any locale with an uppercase region or script subtag (e.g. zh-TW, pt-BR, zh-Hant). Drop the `.toLowerCase()` transform so the value is preserved verbatim; validation stays case-insensitive. This also matches the sibling `localeFilterQuery` (taxonomies/menus), which never lowercased. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#1551) The shared `localeFilterQuery` (used by taxonomy and menu endpoints) still used a plain `z.string()` while `contentListQuery` and the search schemas adopted the stricter, casing-preserving `localeCode`. Reusing `localeCode` here tightens BCP-47 validation and keeps locale handling consistent across the API: a malformed `?locale=` value is now rejected instead of silently matching zero rows. Addresses non-blocking review feedback on #1572. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses review feedback on #1572: removing localeCode's lowercasing transform fixed new locale-filtered queries against the canonical casing, but pre-existing rows already stored under the lowercased form (e.g. zh-tw) were still missed by an exact locale = 'zh-TW' filter. Adds migration 044, which canonicalizes the locale column on every ec_* table against getI18nConfig().locales, case-insensitively. No-op when i18n isn't configured. Not reversible (down is a no-op) -- the original incorrect casing isn't recoverable and re-lowercasing would reintroduce the bug. Updates the "partially applied" migration-runner test to include the new trailing migration.
🦋 Changeset detectedLatest commit: 730e414 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 730e414 | Jul 22 2026, 06:00 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 730e414 | Jul 22 2026, 06:01 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 730e414 | Jul 22 2026, 05:59 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 6a07613 | Jul 21 2026, 01:27 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
Fixes locale-filtered content/search endpoints returning zero rows when the locale includes uppercase subtags (e.g. zh-TW, pt-BR, zh-Hant) by preserving the validated locale string’s original casing and backfilling previously-lowercased stored locales.
Changes:
- Remove lowercasing transform from the
localeCodeschema and applylocaleCodevalidation to the sharedlocaleFilterQuery. - Add migration
054_canonicalize_locale_casingand register it in the migration runner. - Add unit/integration regression tests and a changeset entry for the fix.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/api/schemas/common.ts | Stops lowercasing locale values and tightens ?locale= validation via localeCode. |
| packages/core/src/database/migrations/054_canonicalize_locale_casing.ts | Backfills existing ec_* rows whose locale casing doesn’t match configured locale casing. |
| packages/core/src/database/migrations/runner.ts | Registers migration 054 in the static migration map. |
| packages/core/tests/unit/api/schemas.test.ts | Adds schema-level regression coverage ensuring locale casing is preserved and malformed values reject. |
| packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts | Adds unit coverage for the backfill migration behavior. |
| packages/core/tests/integration/database/migrations.test.ts | Includes migration 054 in integration migration coverage. |
| .changeset/localecode-preserve-casing.md | Publishes release notes for the bug fix and migration backfill. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await sql` | ||
| UPDATE ${table} | ||
| SET locale = ${locale} | ||
| WHERE lower(locale) = lower(${locale}) AND locale != ${locale} | ||
| `.execute(db); |
| import { listTablesLike } from "../dialect-helpers.js"; | ||
|
|
||
| /** | ||
| * Backfill migration for #1572. |
| "emdash": patch | ||
| --- | ||
|
|
||
| Fixes the content and search APIs returning zero results when filtering by a locale with an uppercase region or script subtag (e.g. `?locale=zh-TW`, `pt-BR`, `zh-Hant`). The `localeCode` validator lowercased the value, but config locales, the stored `locale` column, and the public query path all preserve the original casing, so the filter matched nothing. Validation stays case-insensitive; the value is now preserved verbatim. The taxonomy and menu endpoints' shared `?locale=` filter now applies the same BCP-47 validation, so a malformed locale is rejected with a clear error instead of silently matching nothing. A migration backfills existing content rows that were previously stored under the lowercased casing so upgraded sites match immediately, not just new content. Closes #1551. |
There was a problem hiding this comment.
This is the right fix for #1551. Removing the unconditional lowercasing in localeCode and relying on the casing that the Astro config already preserves is the correct direction. The migration is needed, and the tests cover the core backfill path.
I checked the changed schemas, the new migration, the runner registration, the integration migration test, and the unit schema/migration tests. I also traced call-sites of localeCode, localeFilterQuery, getI18nConfig, and the denormalized locale columns in content_taxonomies.
Headline findings:
-
The migration only updates
ec_*tables, not the denormalizedcontent_taxonomies.localecolumn. Migration 051 denormalizes content status/locale/etc. ontocontent_taxonomiesso taxonomy-filtered listings can seek by pivot index, then re-check the real row. Ifec_post.localeis canonicalized tozh-TWbut the pivot still sayszh-tw, the loader’sct.locale = ${locale}predicate will filter those rows out before the re-check ever runs. The migration must keep the pivot in sync, and the unit test should assert that. -
AGENTS.md comment discipline is violated in the new code and tests. Issue/PR references (
#1551,#1572) and PR-describing prose appear in JSDoc blocks and test names. Those references belong in commit messages / PR descriptions, not in code comments. -
Unverified edge-case risk: because SQLite’s
(slug, locale)unique index is case-sensitive, a site that already created a duplicate row under the canonical casing (e.g. because the canonical query returned nothing) will hit aUNIQUE constraint failedwhen the migration tries to canonicalize the older row. I didn’t find logic to detect/merge such duplicates. Worth a sanity-check, but I couldn’t confirm it would occur without the project’s runtime data. -
Future-write casing: explicit
localevalues incontentCreateBodyare now stored verbatim. If a write ever uses a non-canonical casing (e.g.zh-twwhilezh-TWis configured), canonical queries will again return zero rows. The admin picker likely prevents this; flagging as a cautious note rather than a blocker.
The PR is close, but the stale content_taxonomies.locale issue should be fixed before merge.
| for (const tableName of tableNames) { | ||
| const table = sql.ref(tableName); | ||
| for (const locale of locales) { | ||
| await sql` | ||
| UPDATE ${table} | ||
| SET locale = ${locale} | ||
| WHERE lower(locale) = lower(${locale}) AND locale != ${locale} | ||
| `.execute(db); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[needs fixing] The migration canonicalizes locale on every ec_* table, but it leaves the denormalized content_taxonomies.locale column stale. Migration 051 copies locale from ec_* onto content_taxonomies so the loader can index-seek taxonomy+locale filters (loader.ts uses ct.locale = ${locale} before re-checking ec_*). After this migration, the pivot will still say zh-tw while the content row says zh-TW, so taxonomy-filtered listings will miss rows.
Update content_taxonomies in the same per-table loop, using the collection slug (ec_<slug> → collection = <slug>):
| for (const tableName of tableNames) { | |
| const table = sql.ref(tableName); | |
| for (const locale of locales) { | |
| await sql` | |
| UPDATE ${table} | |
| SET locale = ${locale} | |
| WHERE lower(locale) = lower(${locale}) AND locale != ${locale} | |
| `.execute(db); | |
| } | |
| } | |
| } | |
| for (const tableName of tableNames) { | |
| const table = sql.ref(tableName); | |
| const slug = tableName.slice("ec_".length); | |
| for (const locale of locales) { | |
| await sql` | |
| UPDATE ${table} | |
| SET locale = ${locale} | |
| WHERE lower(locale) = lower(${locale}) AND locale != ${locale} | |
| `.execute(db); | |
| await sql` | |
| UPDATE content_taxonomies | |
| SET locale = ${locale} | |
| WHERE collection = ${slug} | |
| AND lower(locale) = lower(${locale}) | |
| AND locale != ${locale} | |
| `.execute(db); | |
| } | |
| } |
Add a unit test that inserts a content_taxonomies row for the lowercased locale and asserts it is also rewritten to the canonical casing.
| import { listTablesLike } from "../dialect-helpers.js"; | ||
|
|
||
| /** | ||
| * Backfill migration for #1572. |
There was a problem hiding this comment.
[needs fixing] AGENTS.md: comments must not reference issues, PRs, or review threads, and should not repeat the PR description. The large JSDoc here is a change narrative. Trim it to a concise, future-facing explanation.
| * Backfill migration for #1572. | |
| /** | |
| * Canonicalize locale casing on existing content rows. | |
| * | |
| * The old `localeCode` schema lowercased explicit values, so a site with a | |
| * configured locale like `zh-TW` could have rows stored as `zh-tw`. Since | |
| * config, columns, and query params now preserve casing, update any row whose | |
| * locale case-insensitively matches a configured locale to that locale's | |
| * canonical form. | |
| */ |
| * BCP 47 locale code — language with optional script/region subtags (e.g. en, en-US, pt-BR, es-419, zh-Hant). | ||
| * Validation is case-insensitive, but the value is preserved verbatim: config `locales`/`defaultLocale`, the | ||
| * stored `locale` column, and the public query path all keep the raw casing, so lowercasing the `?locale=` | ||
| * filter here made it match zero rows for locales with uppercase subtags (#1551). |
There was a problem hiding this comment.
[needs fixing] AGENTS.md: remove the issue reference from the comment. The rationale (preserve raw casing because the stored column and query use it) is worth keeping; the (#1551) marker is not.
| * filter here made it match zero rows for locales with uppercase subtags (#1551). | |
| * stored `locale` column, and the public query path all keep the raw casing, so lowercasing the `?locale=` | |
| * filter here made it match zero rows for locales with uppercase subtags. |
| import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js"; | ||
|
|
||
| /** | ||
| * Regression coverage for #1572. |
There was a problem hiding this comment.
[needs fixing] AGENTS.md: remove the issue reference and PR-describing prose from the comment. The describe name already identifies the migration; the block comment can be deleted or replaced with a one-line purpose.
| * Regression coverage for #1572. | |
| /** Migration 054 rewrites content rows to the configured locale casing. */ |
| * canonical casing, but pre-existing rows would still be missed by an exact | ||
| * `locale = 'zh-TW'` filter without a data backfill. | ||
| */ | ||
| describe("migration 054: canonicalize locale casing (#1572)", () => { |
There was a problem hiding this comment.
[needs fixing] AGENTS.md: test names should not reference issue/PR numbers.
| describe("migration 054: canonicalize locale casing (#1572)", () => { | |
| describe("migration 054: canonicalize locale casing", () => { |
| }); | ||
| }); | ||
|
|
||
| describe("localeCode validator (#1551)", () => { |
There was a problem hiding this comment.
[needs fixing] AGENTS.md: remove the issue reference from the test describe name.
| describe("localeCode validator (#1551)", () => { | |
| describe("localeCode validator", () => { |
There was a problem hiding this comment.
This is the right fix for #1551. Removing the unconditional lowercasing in localeCode and relying on the BCP-47 casing already preserved by Astro config is the correct direction, and the migration back-fills existing ec_* rows and the denormalized content_taxonomies pivot.
What changed since the last review:
- Fixed: the migration now keeps
content_taxonomies.localein sync withec_*. Theup()function updates content tables and then updates pivot rows whose authoritative content row has already been canonicalized, exactly what the loader'sct.locale = ${locale}seek needs. - Fixed: the migration guards against the case-sensitive
(slug, locale)unique index onec_*by skipping rows that would collide with an existing canonical row. - Fixed: issue/PR number references in comments are gone.
Still open: AGENTS.md comment discipline is still violated by PR-describing prose in the new JSDoc, test block comment, migration down(), and migration test. These narrate the bug that was fixed ("made it match zero rows", "pre-fix row", "the bug this fixes") rather than giving a durable "why" to future readers.
Approach check: the change fits EmDash's architecture. localeCode preserving casing works because getI18nConfig() already carries the config's casing, the write path defaults to defaultLocale, and TaxonomyRepository stamps the content row's locale onto new pivot rows. I didn't find another codepath that lowercases locales.
One cautious note: localeFilterQuery now requires BCP-47 format with preserved casing for list endpoints, but createMenuBody, createTaxonomyDefBody, createTermBody, and createRelationBody still accept z.string().min(1) for locale. A write using a non-canonical casing could be stored and then missed by a canonical list query. The admin UI likely forces the canonical value via a picker, so this is a consistency gap rather than a user-visible bug right now.
Headline conclusion: the functional fix is solid and the pivot backfill is now correct. Clean up the remaining comment narrative and consider widening localeCode to the other create bodies, then this can land.
Findings
-
[needs fixing]
packages/core/src/api/schemas/common.ts:56-61The JSDoc explains the non-obvious "why" in its first two sentences, but the final clause narrates the PR bug ("so lowercasing the
?locale=filter here made it match zero rows..."). AGENTS.md says comments must not be PR descriptions or summaries of the change./** * BCP 47 locale code — language with optional script/region subtags (e.g. en, en-US, pt-BR, es-419, zh-Hant). * Validation is case-insensitive, but the value is preserved verbatim because the site config, * stored `locale` columns, and public query path all keep the raw BCP-47 casing. */ -
[needs fixing]
packages/core/tests/unit/api/schemas.test.ts:128-131The block comment restates what the tests assert and then narrates the bug that was fixed ("lowercasing the
?locale=filter ... made it miss every row stored under an uppercase subtag"). Test comments should explain non-obvious invariants, not PR history.describe("localeCode validator", () => { // The site config, stored locale columns, and public query path keep raw BCP-47 casing. // The schema must preserve that casing instead of normalizing it. -
[needs fixing]
packages/core/src/database/migrations/054_canonicalize_locale_casing.ts:46-48The
down()comment is useful in stating the migration is irreversible, but "reintroduce the bug this fixes" references the PR rather than the durable invariant. Keep the irreversibility reason without the bug narrative.export async function down(_db: Kysely<unknown>): Promise<void> { // Not reversible: the original casing is not recoverable. } -
[needs fixing]
packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts:32"pre-fix" is PR-describing narrative. The comment should describe the test setup, not which code version it simulates.
// Simulate a row saved under the lowercased locale. -
[suggestion]
packages/core/src/api/schemas/menus.ts:27createMenuBody.localestill accepts any non-empty string, whilelocaleFilterQuerynow requires a BCP-47 code with preserved casing. A menu created with a non-canonical casing (e.g.zh-twwhen the configured locale iszh-TW) will be stored verbatim and then missed byGET /_emdash/api/menus?locale=zh-TW. The same applies tocreateTaxonomyDefBody.locale,createTermBody.locale, andcreateRelationBody.locale. Consider importinglocaleCodefromcommon.jsand using it for these body schemas as well, so writes cannot introduce a casing that the fixed list filters will miss.
There was a problem hiding this comment.
This is a re-review of the locale-casing fix for #1551. The approach remains correct: preserving BCP-47 casing in localeCode and canonicalizing writes to the configured casing is the right way to fix the SQLite/D1 case-sensitivity mismatch.
The first issue from the prior review is resolved. All the write bodies flagged previously (createMenuBody, createTaxonomyDefBody, createTermBody, createRelationBody, bylineCreateBody, bylineTranslationCreateBody) now use localeCode, and the corresponding handlers plus the admin bylines route call resolveConfiguredLocale before storing or filtering. That gap is closed.
Two issues remain:
-
Taxonomy read endpoints do not canonicalize
?locale=.handleTaxonomyList,handleTermList,handleTermGet,handleTermUpdate, andhandleTermDeletepass the raw query-string locale directly to the repository. BecauselocaleCodenow preserves caller casing, a request like?locale=zh-twwill query for exact'zh-tw'rows and miss terms/defs stored with canonical'zh-TW'by the fixed write handlers. This is the same class of casing mismatch the PR is meant to eliminate, just moved to the taxonomy path. Content, menu, relation, and byline endpoints already canonicalize; taxonomy reads should too. -
The runtime repair is still narrower than the changeset claims.
repairLocaleCasingonly rewritesec_%content tables andcontent_taxonomies, but the changeset says it fixes “locale-filtered content, search, taxonomy, and menu results” and that “existing content rows saved with lowercased locale values are repaired automatically.” Existing rows in_emdash_menus,_emdash_taxonomy_defs,taxonomies,_emdash_relations,_emdash_bylines, and_emdash_menu_itemsare not repaired, so the new locale-preserving filters still miss historical lowercased values there.
Both are fixable extensions of the same pattern the PR already uses for content.
Findings
-
[needs fixing]
packages/core/src/api/handlers/taxonomies.ts:179Taxonomy definition list filters by
options.localewithout canonicalizing it to the configured casing. SincelocaleCodenow preserves caller casing, a request likeGET /_emdash/api/taxonomies?locale=zh-twqueries_emdash_taxonomy_defs.locale = 'zh-tw'and misses definitions stored with canonicalzh-TWbyhandleTaxonomyCreate.handleContentList,handleMenuList, andhandleRelationListalready canonicalize the locale filter viaresolveConfiguredLocale; taxonomy reads should do the same:const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; if (locale !== undefined) query = query.where("locale", "=", locale); -
[needs fixing]
packages/core/src/api/handlers/taxonomies.ts:395Term read paths also pass the raw
options.localeto the repository:handleTermList(line 395),handleTermGet(line 641),handleTermUpdate(line 749), andhandleTermDelete(line 838). WithlocaleCodepreserving caller casing andhandleTermCreatestoring canonicalzh-TW, a?locale=zh-twlookup returns no results. Canonicalizeoptions.localebefore passing it torepo.findByName/repo.findBySlug:const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; const terms = await repo.findByName(taxonomyName, { locale });The same fix applies to the
repo.findBySlug(taxonomyName, termSlug, locale)calls at lines 641, 749, and 838. -
[needs fixing]
packages/core/src/i18n/repair-locale-casing.ts:12repairLocaleCasingonly discoversec_%content tables and then updatescontent_taxonomies, so it does not repair existing locale values in_emdash_menus,_emdash_taxonomy_defs,taxonomies,_emdash_relations,_emdash_bylines, or_emdash_menu_items.The changeset says the fix covers “locale-filtered content, search, taxonomy, and menu results” and that “existing content rows saved with lowercased locale values are repaired automatically.” Historical lowercased rows in those tables remain missed by the new locale-preserving list filters. Either extend
repairLocaleCasingto those tables (with the same slug-uniqueness collision handling used for content), or narrow the changeset to the tables the repair actually rewrites:Fixes locale-filtered content and search results for locales with uppercase region or script subtags. Existing content rows saved with lowercased locale values are repaired automatically.
There was a problem hiding this comment.
This is the right fix for #1551: preserve BCP-47 casing in localeCode, resolve every caller-supplied locale to the exact casing configured for the site, canonicalize writes, and repair existing content rows at runtime. Both issues from the prior review are addressed: taxonomy read endpoints (handleTaxonomyList, handleTermList, handleTermGet, handleTermUpdate, handleTermDelete) now call resolveConfiguredLocale, and the changeset was narrowed to match the tables repairLocaleCasing actually rewrites (ec_% content tables and content_taxonomies pivots). The implementation fits EmDash's handler/repository split and follows SQL-safety conventions.
I checked the diff, the changed handler/schema/seed/runtime files, and call sites for the new resolveConfiguredLocale. The only remaining gap is the public search/suggest path. searchQuery and searchSuggestQuery were updated to use localeCode (so casing is preserved), but packages/core/src/search/query.ts still passes options.locale straight into the FTS SQL. A GET /_emdash/api/search?q=hello&locale=zh-tw will therefore look for rows whose locale is exactly 'zh-tw', miss the canonical 'zh-TW' rows stored by the fixed write path, and return empty results. Content, menu, relation, byline, and taxonomy handlers all canonicalize before querying; search should do the same.
This is a fixable extension of the same pattern already applied everywhere else. Once searchSingleCollection and getSuggestions resolve the locale, the changeset's claim that "search results" are fixed will be accurate.
Findings
-
[needs fixing]
packages/core/src/search/query.ts:253searchSingleCollectionfiltersc.localewith the rawoptions.localevalue. BecausesearchQuerynow preserves caller casing, a request like?locale=zh-twqueries for exact'zh-tw'and misses entries indexed under canonical'zh-TW'by the fixed write path. Content, menu, relation, byline, and taxonomy handlers all callresolveConfiguredLocalebefore filtering; search should too.Add the import and canonicalize the locale before building the FTS SQL:
import { resolveConfiguredLocale } from "../i18n/config.js";const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;The same change is needed for
getSuggestionsaround line 400 (const locale = options.locale;). A regression test should cover searching/suggesting with a lowercase input locale against content stored with canonical casing (e.g.zh-TW).
There was a problem hiding this comment.
This is the right fix for #1551 and the follow-up addresses the remaining gap from the prior review.
What changed since last review:
packages/core/src/search/query.tsnow canonicalizes the locale in bothsearchSingleCollectionandgetSuggestionsviaresolveConfiguredLocale, so public search/suggest requests with?locale=zh-twcorrectly matchzh-TWrows stored by the fixed write path.- Regression tests in
tests/integration/search/suggest.test.tscover search and suggest with lowercase input against canonical-cased content.
What I checked:
- The diff, all changed handler/schema/seed/runtime files, and the new
repairLocaleCasingimplementation. - SQL safety in
repairLocaleCasing: table identifiers come fromlistTablesLikeand are passed throughsql.ref(); configured locale values are parameterized; no raw interpolation of untrusted input. - Call sites for
resolveConfiguredLocaleacross content, menus, relations, taxonomies, bylines, and now search/suggest — every locale-filtered read/write path in the diff canonicalizes. - The runtime repair gating: it only runs when at least one configured locale has a hyphen or uppercase letter, or a previous repair key exists and the version changed, then records the new version in
options. - Tests added for canonical create/list/get, seed validation, runtime repair, and case-variant collision handling.
Headline conclusion: the prior finding is resolved, the change is consistent with EmDash's handler/repository split, and the patch appears ready to merge.
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
Visual regression: 1 screen changedThese admin screens render differently on this PR. Confirm every change below is intended before accepting.
Maintainers: if every change above is intended, comment Measured head: |
|
/accept-baselines |
|
ℹ️ The committed baselines already match the candidates; nothing to commit. |

What does this PR do?
Fixes content and search APIs returning zero results when filtering by a locale with an uppercase region or script subtag, such as
zh-TW,pt-BR, orzh-Hant.The
localeCodevalidator lowercased explicit locale values while configuration, stored content, and public queries preserve BCP-47 casing. SQLite and D1 compare these values case-sensitively. This preserves validated input casing and canonicalizes existing content rows to configured locale casing.This replaces #1572 because its organization-owned fork rejected maintainer pushes while resolving merge conflicts.
Closes #1551
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
Verified after merging current
main:pnpm lint:quickpnpm --filter emdash exec vitest run tests/unit/api/schemas.test.ts tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts(48 tests)pnpm --filter emdash exec vitest run tests/integration/database/migrations.test.ts(19 tests)pnpm buildpnpm typecheckTry this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
fix/1551-locale-casing. Updated automatically when the playground redeploys.