diff --git a/.changeset/localecode-preserve-casing.md b/.changeset/localecode-preserve-casing.md new file mode 100644 index 000000000..fbe64671c --- /dev/null +++ b/.changeset/localecode-preserve-casing.md @@ -0,0 +1,5 @@ +--- +"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. Closes #1551. diff --git a/packages/core/src/api/schemas/common.ts b/packages/core/src/api/schemas/common.ts index 292655381..4f623e912 100644 --- a/packages/core/src/api/schemas/common.ts +++ b/packages/core/src/api/schemas/common.ts @@ -53,16 +53,18 @@ export const httpUrl = z .url() .refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https"); -/** BCP 47 locale code — language with optional script/region subtags (e.g. en, en-US, pt-BR, es-419, zh-Hant) */ -export const localeCode = z - .string() - .regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code") - .transform((v) => v.toLowerCase()); +/** + * 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). + */ +export const localeCode = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code"); /** Shared `?locale=xx` query shape for endpoints that filter by locale. */ export const localeFilterQuery = z .object({ - locale: z.string().min(1).optional(), + locale: localeCode.optional(), }) .meta({ id: "LocaleFilterQuery" }); diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index 13be8052b..5265c1477 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -2,10 +2,13 @@ import { describe, it, expect } from "vitest"; import { contentCreateBody, + contentListQuery, contentUpdateBody, createFieldBody, updateFieldBody, httpUrl, + localeCode, + localeFilterQuery, mediaUploadUrlBody, DEFAULT_MAX_UPLOAD_SIZE, } from "../../../src/api/schemas/index.js"; @@ -121,6 +124,48 @@ describe("contentUpdateBody schema", () => { }); }); +describe("localeCode validator (#1551)", () => { + // Config `locales`/`defaultLocale`, the `locale` column default, and the + // public query path all keep the raw BCP-47 casing (e.g. "zh-TW"). The API + // schema must do the same — lowercasing the `?locale=` filter (or a create + // body's locale) made it miss every row stored under an uppercase subtag. + it("preserves region/script subtag casing", () => { + expect(localeCode.parse("zh-TW")).toBe("zh-TW"); + expect(localeCode.parse("pt-BR")).toBe("pt-BR"); + expect(localeCode.parse("zh-Hant")).toBe("zh-Hant"); + }); + + it("leaves plain language codes unchanged", () => { + expect(localeCode.parse("en")).toBe("en"); + }); + + it("still accepts mixed-case input (case-insensitive validation)", () => { + expect(localeCode.parse("EN-us")).toBe("EN-us"); + }); + + it("rejects malformed locale codes", () => { + expect(() => localeCode.parse("english")).toThrow(); + expect(() => localeCode.parse("e")).toThrow(); + expect(() => localeCode.parse("en_US")).toThrow(); + }); + + it("contentListQuery keeps the ?locale= filter casing", () => { + const result = contentListQuery.parse({ locale: "zh-TW" }); + expect(result.locale).toBe("zh-TW"); + }); + + it("contentCreateBody keeps the locale casing", () => { + const result = contentCreateBody.parse({ data: { title: "Hi" }, locale: "pt-BR" }); + expect(result.locale).toBe("pt-BR"); + }); + + it("localeFilterQuery keeps the ?locale= casing and rejects malformed values", () => { + expect(localeFilterQuery.parse({ locale: "zh-TW" }).locale).toBe("zh-TW"); + expect(localeFilterQuery.parse({}).locale).toBeUndefined(); + expect(() => localeFilterQuery.parse({ locale: "_invalid_" })).toThrow(); + }); +}); + describe("httpUrl validator", () => { it("accepts http URLs", () => { expect(httpUrl.parse("http://example.com")).toBe("http://example.com");