Skip to content

Commit 960d5ba

Browse files
fix(core): preserve locale casing in API locale filter (emdash-cms#1551)
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>
1 parent bc1e26d commit 960d5ba

3 files changed

Lines changed: 50 additions & 5 deletions

File tree

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 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. Closes #1551.

packages/core/src/api/schemas/common.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,13 @@ export const httpUrl = z
5353
.url()
5454
.refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https");
5555

56-
/** BCP 47 locale code — language with optional script/region subtags (e.g. en, en-US, pt-BR, es-419, zh-Hant) */
57-
export const localeCode = z
58-
.string()
59-
.regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code")
60-
.transform((v) => v.toLowerCase());
56+
/**
57+
* BCP 47 locale code — language with optional script/region subtags (e.g. en, en-US, pt-BR, es-419, zh-Hant).
58+
* Validation is case-insensitive, but the value is preserved verbatim: config `locales`/`defaultLocale`, the
59+
* stored `locale` column, and the public query path all keep the raw casing, so lowercasing the `?locale=`
60+
* filter here made it match zero rows for locales with uppercase subtags (#1551).
61+
*/
62+
export const localeCode = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code");
6163

6264
/** Shared `?locale=xx` query shape for endpoints that filter by locale. */
6365
export const localeFilterQuery = z

packages/core/tests/unit/api/schemas.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { describe, it, expect } from "vitest";
22

33
import {
44
contentCreateBody,
5+
contentListQuery,
56
contentUpdateBody,
67
createFieldBody,
78
updateFieldBody,
89
httpUrl,
10+
localeCode,
911
mediaUploadUrlBody,
1012
DEFAULT_MAX_UPLOAD_SIZE,
1113
} from "../../../src/api/schemas/index.js";
@@ -121,6 +123,42 @@ describe("contentUpdateBody schema", () => {
121123
});
122124
});
123125

126+
describe("localeCode validator (#1551)", () => {
127+
// Config `locales`/`defaultLocale`, the `locale` column default, and the
128+
// public query path all keep the raw BCP-47 casing (e.g. "zh-TW"). The API
129+
// schema must do the same — lowercasing the `?locale=` filter (or a create
130+
// body's locale) made it miss every row stored under an uppercase subtag.
131+
it("preserves region/script subtag casing", () => {
132+
expect(localeCode.parse("zh-TW")).toBe("zh-TW");
133+
expect(localeCode.parse("pt-BR")).toBe("pt-BR");
134+
expect(localeCode.parse("zh-Hant")).toBe("zh-Hant");
135+
});
136+
137+
it("leaves plain language codes unchanged", () => {
138+
expect(localeCode.parse("en")).toBe("en");
139+
});
140+
141+
it("still accepts mixed-case input (case-insensitive validation)", () => {
142+
expect(localeCode.parse("EN-us")).toBe("EN-us");
143+
});
144+
145+
it("rejects malformed locale codes", () => {
146+
expect(() => localeCode.parse("english")).toThrow();
147+
expect(() => localeCode.parse("e")).toThrow();
148+
expect(() => localeCode.parse("en_US")).toThrow();
149+
});
150+
151+
it("contentListQuery keeps the ?locale= filter casing", () => {
152+
const result = contentListQuery.parse({ locale: "zh-TW" });
153+
expect(result.locale).toBe("zh-TW");
154+
});
155+
156+
it("contentCreateBody keeps the locale casing", () => {
157+
const result = contentCreateBody.parse({ data: { title: "Hi" }, locale: "pt-BR" });
158+
expect(result.locale).toBe("pt-BR");
159+
});
160+
});
161+
124162
describe("httpUrl validator", () => {
125163
it("accepts http URLs", () => {
126164
expect(httpUrl.parse("http://example.com")).toBe("http://example.com");

0 commit comments

Comments
 (0)