Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/localecode-preserve-casing.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 8 additions & 6 deletions packages/core/src/api/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down
45 changes: 45 additions & 0 deletions packages/core/tests/unit/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Loading