From 03525a69ac3e6610402487cfab087383085a2772 Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Sun, 21 Jun 2026 17:08:09 -0500 Subject: [PATCH 1/2] fix(core): preserve locale casing in API locale filter (#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 --- .changeset/localecode-preserve-casing.md | 5 +++ packages/core/src/api/schemas/common.ts | 12 ++++--- packages/core/tests/unit/api/schemas.test.ts | 38 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 .changeset/localecode-preserve-casing.md diff --git a/.changeset/localecode-preserve-casing.md b/.changeset/localecode-preserve-casing.md new file mode 100644 index 000000000..03caa11b0 --- /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. Closes #1551. diff --git a/packages/core/src/api/schemas/common.ts b/packages/core/src/api/schemas/common.ts index 292655381..225f98f7d 100644 --- a/packages/core/src/api/schemas/common.ts +++ b/packages/core/src/api/schemas/common.ts @@ -53,11 +53,13 @@ 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 diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index 13be8052b..50ee25719 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect } from "vitest"; import { contentCreateBody, + contentListQuery, contentUpdateBody, createFieldBody, updateFieldBody, httpUrl, + localeCode, mediaUploadUrlBody, DEFAULT_MAX_UPLOAD_SIZE, } from "../../../src/api/schemas/index.js"; @@ -121,6 +123,42 @@ 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"); + }); +}); + describe("httpUrl validator", () => { it("accepts http URLs", () => { expect(httpUrl.parse("http://example.com")).toBe("http://example.com"); From e288db7ebdb8f76247f8f21ebfe7f6e547674f23 Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Tue, 23 Jun 2026 19:22:27 -0500 Subject: [PATCH 2/2] fix(core): apply localeCode validation to taxonomy/menu locale filter (#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 --- .changeset/localecode-preserve-casing.md | 2 +- packages/core/src/api/schemas/common.ts | 2 +- packages/core/tests/unit/api/schemas.test.ts | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/localecode-preserve-casing.md b/.changeset/localecode-preserve-casing.md index 03caa11b0..fbe64671c 100644 --- a/.changeset/localecode-preserve-casing.md +++ b/.changeset/localecode-preserve-casing.md @@ -2,4 +2,4 @@ "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. Closes #1551. +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 225f98f7d..4f623e912 100644 --- a/packages/core/src/api/schemas/common.ts +++ b/packages/core/src/api/schemas/common.ts @@ -64,7 +64,7 @@ export const localeCode = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "In /** 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 50ee25719..5265c1477 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -8,6 +8,7 @@ import { updateFieldBody, httpUrl, localeCode, + localeFilterQuery, mediaUploadUrlBody, DEFAULT_MAX_UPLOAD_SIZE, } from "../../../src/api/schemas/index.js"; @@ -157,6 +158,12 @@ describe("localeCode validator (#1551)", () => { 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", () => {