From 285420714e61da86b7d651661a320436372d87f2 Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Sun, 21 Jun 2026 17:08:09 -0500 Subject: [PATCH 1/9] 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 0000000000..03caa11b09 --- /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 292655381b..225f98f7dd 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 a8568f0fca..8b77cf2340 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 47575c3dfdaec0724d0cb9e40128c9a3eda0945a Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Tue, 23 Jun 2026 19:22:27 -0500 Subject: [PATCH 2/9] 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 03caa11b09..fbe64671c0 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 225f98f7dd..4f623e9127 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 8b77cf2340..76764302c6 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", () => { From bc185e1103caa737d7add9006a75549dcd31afc7 Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Fri, 3 Jul 2026 16:21:16 -0500 Subject: [PATCH 3/9] fix(core): backfill locale casing on ec_* tables (#1572) 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/localecode-preserve-casing.md | 2 +- .../052_canonicalize_locale_casing.ts | 43 +++++++++ .../core/src/database/migrations/runner.ts | 2 + .../integration/database/migrations.test.ts | 1 + .../052_canonicalize_locale_casing.test.ts | 95 +++++++++++++++++++ 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/database/migrations/052_canonicalize_locale_casing.ts create mode 100644 packages/core/tests/unit/database/migrations/052_canonicalize_locale_casing.test.ts diff --git a/.changeset/localecode-preserve-casing.md b/.changeset/localecode-preserve-casing.md index fbe64671c0..f0911cc0df 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. 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. +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. diff --git a/packages/core/src/database/migrations/052_canonicalize_locale_casing.ts b/packages/core/src/database/migrations/052_canonicalize_locale_casing.ts new file mode 100644 index 0000000000..3e5cd077b8 --- /dev/null +++ b/packages/core/src/database/migrations/052_canonicalize_locale_casing.ts @@ -0,0 +1,43 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +import { getI18nConfig } from "../../i18n/config.js"; +import { listTablesLike } from "../dialect-helpers.js"; + +/** + * Backfill migration for #1572. + * + * `contentCreateBody`'s `locale` field used to lowercase every explicit + * value (`localeCode`'s `.transform()`), so a site with a configured locale + * like `zh-TW` could end up with rows stored as `zh-tw`. The transform was + * removed so canonical-cased queries (`?locale=zh-TW`) now match, but that + * only fixes future writes -- existing rows already stored under the + * lowercased form are still missed by an exact-match `locale = 'zh-TW'` + * filter. + * + * For every `ec_*` content table, canonicalize any row whose `locale` + * case-insensitively matches a configured locale but isn't stored in that + * locale's canonical casing. + */ +export async function up(db: Kysely): Promise { + const locales = getI18nConfig()?.locales ?? []; + if (locales.length === 0) return; + + const tableNames = await listTablesLike(db, "ec_%"); + + 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); + } + } +} + +export async function down(_db: Kysely): Promise { + // Not reversible: the original (incorrectly lowercased) casing is not + // recoverable, and re-lowercasing would reintroduce the bug this fixes. +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index ca2020144e..6e38212f1f 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -54,6 +54,7 @@ import * as m048 from "./048_restore_content_taxonomies_term_index.js"; import * as m049 from "./049_taxonomies_name_locale_index.js"; import * as m050 from "./050_media_usage_index_status.js"; import * as m051 from "./051_content_taxonomies_denorm.js"; +import * as m052 from "./052_canonicalize_locale_casing.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -106,6 +107,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "049_taxonomies_name_locale_index": m049, "050_media_usage_index_status": m050, "051_content_taxonomies_denorm": m051, + "052_canonicalize_locale_casing": m052, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 6380bf5d04..03a2e87d9c 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -138,6 +138,7 @@ describe("Database Migrations (Integration)", () => { "049_taxonomies_name_locale_index", "050_media_usage_index_status", "051_content_taxonomies_denorm", + "052_canonicalize_locale_casing", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/unit/database/migrations/052_canonicalize_locale_casing.test.ts b/packages/core/tests/unit/database/migrations/052_canonicalize_locale_casing.test.ts new file mode 100644 index 0000000000..f3346f182d --- /dev/null +++ b/packages/core/tests/unit/database/migrations/052_canonicalize_locale_casing.test.ts @@ -0,0 +1,95 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { up } from "../../../../src/database/migrations/052_canonicalize_locale_casing.js"; +import { ContentRepository } from "../../../../src/database/repositories/content.js"; +import type { Database } from "../../../../src/database/types.js"; +import { getI18nConfig, setI18nConfig } from "../../../../src/i18n/config.js"; +import { createPostFixture } from "../../../utils/fixtures.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js"; + +/** + * Regression coverage for #1572. + * + * `contentCreateBody` used to lowercase every explicit `locale` value, so a + * site with a configured locale like `zh-TW` could have existing rows stored + * as `zh-tw`. Removing that transform fixed new queries against the + * canonical casing, but pre-existing rows would still be missed by an exact + * `locale = 'zh-TW'` filter without a data backfill. + */ +describe("migration 052: canonicalize locale casing (#1572)", () => { + let db: Kysely; + const previousI18nConfig = getI18nConfig(); + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + }); + + afterEach(async () => { + setI18nConfig(previousI18nConfig); + await teardownTestDatabase(db); + }); + + it("rewrites rows stored in the wrong case to the configured locale's canonical casing", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + + const repo = new ContentRepository(db); + await repo.create( + createPostFixture({ slug: "guide", status: "published", data: { title: "Guide" } }), + ); + // Simulate a pre-fix row: written under the lowercased locale. + await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'guide'`.execute(db); + + await up(db); + + const row = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "guide") + .executeTakeFirstOrThrow(); + expect(row.locale).toBe("zh-TW"); + }); + + it("leaves rows already in canonical casing untouched", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + + const repo = new ContentRepository(db); + await repo.create( + createPostFixture({ + slug: "already-correct", + status: "published", + data: { title: "Correct" }, + locale: "zh-TW", + }), + ); + + await up(db); + + const row = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "already-correct") + .executeTakeFirstOrThrow(); + expect(row.locale).toBe("zh-TW"); + }); + + it("does nothing when i18n is not configured", async () => { + setI18nConfig(null); + + const repo = new ContentRepository(db); + await repo.create( + createPostFixture({ slug: "no-i18n", status: "published", data: { title: "No i18n" } }), + ); + await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'no-i18n'`.execute(db); + + await up(db); + + const row = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "no-i18n") + .executeTakeFirstOrThrow(); + expect(row.locale).toBe("zh-tw"); + }); +}); From c472cf45c3ffe2c1c11383d5e7cd71ba025b1060 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 21 Jul 2026 15:02:42 +0100 Subject: [PATCH 4/9] fix(core): keep locale taxonomy pivots in sync --- .changeset/localecode-preserve-casing.md | 2 +- packages/core/src/api/schemas/common.ts | 2 +- .../054_canonicalize_locale_casing.ts | 40 +++++++----- packages/core/tests/unit/api/schemas.test.ts | 2 +- .../054_canonicalize_locale_casing.test.ts | 63 +++++++++++++++---- 5 files changed, 76 insertions(+), 33 deletions(-) diff --git a/.changeset/localecode-preserve-casing.md b/.changeset/localecode-preserve-casing.md index f0911cc0df..fb13dbfe74 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. 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. +Fixes locale-filtered content, search, taxonomy, and menu results for locales with uppercase region or script subtags, including existing content saved with lowercased locale values. diff --git a/packages/core/src/api/schemas/common.ts b/packages/core/src/api/schemas/common.ts index d74c1c7497..8d90ba5e20 100644 --- a/packages/core/src/api/schemas/common.ts +++ b/packages/core/src/api/schemas/common.ts @@ -57,7 +57,7 @@ export const httpUrl = z * 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). + * filter here made it match zero rows for locales with uppercase subtags. */ export const localeCode = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code"); diff --git a/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts b/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts index 3e5cd077b8..42a0253d89 100644 --- a/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts +++ b/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts @@ -4,21 +4,7 @@ import { sql } from "kysely"; import { getI18nConfig } from "../../i18n/config.js"; import { listTablesLike } from "../dialect-helpers.js"; -/** - * Backfill migration for #1572. - * - * `contentCreateBody`'s `locale` field used to lowercase every explicit - * value (`localeCode`'s `.transform()`), so a site with a configured locale - * like `zh-TW` could end up with rows stored as `zh-tw`. The transform was - * removed so canonical-cased queries (`?locale=zh-TW`) now match, but that - * only fixes future writes -- existing rows already stored under the - * lowercased form are still missed by an exact-match `locale = 'zh-TW'` - * filter. - * - * For every `ec_*` content table, canonicalize any row whose `locale` - * case-insensitively matches a configured locale but isn't stored in that - * locale's canonical casing. - */ +/** Canonicalize stored locales to the casing used by the site configuration. */ export async function up(db: Kysely): Promise { const locales = getI18nConfig()?.locales ?? []; if (locales.length === 0) return; @@ -27,11 +13,31 @@ export async function up(db: Kysely): Promise { 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} + UPDATE ${table} AS target SET locale = ${locale} - WHERE lower(locale) = lower(${locale}) AND locale != ${locale} + WHERE lower(target.locale) = lower(${locale}) + AND target.locale != ${locale} + AND NOT EXISTS ( + SELECT 1 + FROM ${table} AS existing + WHERE existing.slug = target.slug AND existing.locale = ${locale} + ) + `.execute(db); + + await sql` + UPDATE content_taxonomies AS pivot + SET locale = ${locale} + WHERE pivot.collection = ${slug} + AND lower(pivot.locale) = lower(${locale}) + AND pivot.locale != ${locale} + AND EXISTS ( + SELECT 1 + FROM ${table} AS content + WHERE content.id = pivot.entry_id AND content.locale = ${locale} + ) `.execute(db); } } diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index 76764302c6..180862f77c 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -124,7 +124,7 @@ describe("contentUpdateBody schema", () => { }); }); -describe("localeCode validator (#1551)", () => { +describe("localeCode validator", () => { // 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 diff --git a/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts b/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts index 6cb0bf50fa..0c1bc32292 100644 --- a/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts +++ b/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts @@ -9,16 +9,7 @@ import { getI18nConfig, setI18nConfig } from "../../../../src/i18n/config.js"; import { createPostFixture } from "../../../utils/fixtures.js"; import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js"; -/** - * Regression coverage for #1572. - * - * `contentCreateBody` used to lowercase every explicit `locale` value, so a - * site with a configured locale like `zh-TW` could have existing rows stored - * as `zh-tw`. Removing that transform fixed new queries against the - * 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)", () => { +describe("migration 054: canonicalize locale casing", () => { let db: Kysely; const previousI18nConfig = getI18nConfig(); @@ -35,20 +26,66 @@ describe("migration 054: canonicalize locale casing (#1572)", () => { setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); const repo = new ContentRepository(db); - await repo.create( + const post = await repo.create( createPostFixture({ slug: "guide", status: "published", data: { title: "Guide" } }), ); // Simulate a pre-fix row: written under the lowercased locale. await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'guide'`.execute(db); + await db + .insertInto("taxonomies") + .values({ + id: "category-news", + name: "category", + slug: "news", + label: "News", + parent_id: null, + data: null, + locale: "en", + translation_group: "category-news", + }) + .execute(); + await db + .insertInto("content_taxonomies") + .values({ + collection: "post", + entry_id: post.id, + taxonomy_id: "category-news", + locale: "zh-tw", + }) + .execute(); await up(db); - const row = await db + const contentRow = await db .selectFrom("ec_post") .select("locale") .where("slug", "=", "guide") .executeTakeFirstOrThrow(); - expect(row.locale).toBe("zh-TW"); + const pivotRow = await db + .selectFrom("content_taxonomies") + .select("locale") + .where("entry_id", "=", post.id) + .executeTakeFirstOrThrow(); + expect(contentRow.locale).toBe("zh-TW"); + expect(pivotRow.locale).toBe("zh-TW"); + }); + + it("preserves case-variant rows when canonicalizing would violate slug uniqueness", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + + const repo = new ContentRepository(db); + await repo.create(createPostFixture({ slug: "guide", locale: "zh-tw" })); + await repo.create(createPostFixture({ slug: "guide", locale: "zh-TW" })); + + await expect(up(db)).resolves.toBeUndefined(); + + const rows = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "guide") + .orderBy("locale") + .execute(); + expect(rows.map((row) => row.locale)).toEqual(["zh-TW", "zh-tw"]); }); it("leaves rows already in canonical casing untouched", async () => { From 26c5a340f7d10ca0a132718d82807ba8cb0fdfca Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 21 Jul 2026 15:14:33 +0100 Subject: [PATCH 5/9] chore(core): clarify locale casing comments --- packages/core/src/api/schemas/common.ts | 5 ++--- .../database/migrations/054_canonicalize_locale_casing.ts | 3 +-- packages/core/tests/unit/api/schemas.test.ts | 6 ++---- .../migrations/054_canonicalize_locale_casing.test.ts | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/core/src/api/schemas/common.ts b/packages/core/src/api/schemas/common.ts index 8d90ba5e20..3c04483c48 100644 --- a/packages/core/src/api/schemas/common.ts +++ b/packages/core/src/api/schemas/common.ts @@ -55,9 +55,8 @@ export const httpUrl = z /** * 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. + * 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. */ export const localeCode = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code"); diff --git a/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts b/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts index 42a0253d89..1191b854c4 100644 --- a/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts +++ b/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts @@ -44,6 +44,5 @@ export async function up(db: Kysely): Promise { } export async function down(_db: Kysely): Promise { - // Not reversible: the original (incorrectly lowercased) casing is not - // recoverable, and re-lowercasing would reintroduce the bug this fixes. + // Not reversible: the original casing is not recoverable. } diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index 180862f77c..7346c699bf 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -125,10 +125,8 @@ describe("contentUpdateBody schema", () => { }); describe("localeCode validator", () => { - // 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. + // The site config, stored locale columns, and public query path keep raw + // BCP-47 casing, so the schema must preserve it instead of normalizing it. it("preserves region/script subtag casing", () => { expect(localeCode.parse("zh-TW")).toBe("zh-TW"); expect(localeCode.parse("pt-BR")).toBe("pt-BR"); diff --git a/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts b/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts index 0c1bc32292..ee70ea30ea 100644 --- a/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts +++ b/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts @@ -29,7 +29,7 @@ describe("migration 054: canonicalize locale casing", () => { const post = await repo.create( createPostFixture({ slug: "guide", status: "published", data: { title: "Guide" } }), ); - // Simulate a pre-fix row: written under the lowercased locale. + // Simulate a row saved under the lowercased locale. await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'guide'`.execute(db); await db .insertInto("taxonomies") From b08043766f9175321c3cb8f695950c9e6bb80942 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 21 Jul 2026 18:24:45 +0100 Subject: [PATCH 6/9] fix(core): repair locale casing from runtime config --- packages/core/src/api/handlers/content.ts | 32 +- .../core/src/database/migrations/runner.ts | 2 - packages/core/src/emdash-runtime.ts | 28 ++ packages/core/src/i18n/config.ts | 8 + .../repair-locale-casing.ts} | 27 +- packages/core/src/seed/apply.ts | 16 +- packages/core/src/seed/validate.ts | 19 +- .../integration/database/migrations.test.ts | 1 - .../tests/integration/runtime/create.test.ts | 81 +++++ .../054_canonicalize_locale_casing.test.ts | 132 -------- .../unit/i18n/repair-locale-casing.test.ts | 290 ++++++++++++++++++ 11 files changed, 471 insertions(+), 165 deletions(-) rename packages/core/src/{database/migrations/054_canonicalize_locale_casing.ts => i18n/repair-locale-casing.ts} (61%) delete mode 100644 packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts create mode 100644 packages/core/tests/unit/i18n/repair-locale-casing.test.ts diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 33826b4604..db0f4a0a6d 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -30,7 +30,7 @@ import { UserRepository } from "../../database/repositories/user.js"; import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; -import { getI18nConfig, isI18nEnabled } from "../../i18n/config.js"; +import { getI18nConfig, isI18nEnabled, resolveConfiguredLocale } from "../../i18n/config.js"; import { invalidateRedirectCache } from "../../redirects/cache.js"; import { FTSManager } from "../../search/fts-manager.js"; import { invalidateTermCache } from "../../taxonomies/index.js"; @@ -269,7 +269,11 @@ async function resolveId( identifier: string, locale?: string, ): Promise { - const item = await repo.findByIdOrSlug(collection, identifier, locale); + const item = await repo.findByIdOrSlug( + collection, + identifier, + locale ? resolveConfiguredLocale(locale) : undefined, + ); return item?.id ?? null; } @@ -283,7 +287,11 @@ async function resolveIdIncludingTrashed( identifier: string, locale?: string, ): Promise { - const item = await repo.findByIdOrSlugIncludingTrashed(collection, identifier, locale); + const item = await repo.findByIdOrSlugIncludingTrashed( + collection, + identifier, + locale ? resolveConfiguredLocale(locale) : undefined, + ); return item?.id ?? null; } @@ -427,7 +435,7 @@ export async function handleContentList( const repo = new ContentRepository(db); const where: FindManyOptions["where"] = {}; if (params.status) where.status = params.status; - if (params.locale) where.locale = params.locale; + if (params.locale) where.locale = resolveConfiguredLocale(params.locale); if (params.authorId) where.authorId = params.authorId; // A date range requires a target column; ignore stray from/to without @@ -579,7 +587,11 @@ export async function handleContentGet( ): Promise> { try { const repo = new ContentRepository(db); - const item = await repo.findByIdOrSlug(collection, id, locale); + const item = await repo.findByIdOrSlug( + collection, + id, + locale ? resolveConfiguredLocale(locale) : undefined, + ); if (!item) { return { @@ -624,7 +636,11 @@ export async function handleContentGetIncludingTrashed( ): Promise> { try { const repo = new ContentRepository(db); - const item = await repo.findByIdOrSlugIncludingTrashed(collection, id, locale); + const item = await repo.findByIdOrSlugIncludingTrashed( + collection, + id, + locale ? resolveConfiguredLocale(locale) : undefined, + ); if (!item) { return { @@ -706,7 +722,9 @@ export async function handleContentCreate( // Default to the configured site locale rather than the repo's // hard-coded "en" — otherwise non-English default-locale sites // silently create entries in a locale the editor never chose. - const effectiveLocale = body.locale ?? getI18nConfig()?.defaultLocale; + const effectiveLocale = body.locale + ? resolveConfiguredLocale(body.locale) + : getI18nConfig()?.defaultLocale; let slug: string | null | undefined = body.slug; if (!slug) { diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 0158ef2ab1..a3ce955d45 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -56,7 +56,6 @@ import * as m050 from "./050_media_usage_index_status.js"; import * as m051 from "./051_content_taxonomies_denorm.js"; import * as m052 from "./052_media_usage_read_index.js"; import * as m053 from "./053_plugin_mcp_tools.js"; -import * as m054 from "./054_canonicalize_locale_casing.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -111,7 +110,6 @@ const MIGRATIONS: Readonly> = Object.freeze({ "051_content_taxonomies_denorm": m051, "052_media_usage_read_index": m052, "053_plugin_mcp_tools": m053, - "054_canonicalize_locale_casing": m054, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 52f50962a0..00121d4de1 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -36,6 +36,8 @@ import type { ContentDateField, } from "./database/repositories/types.js"; import { validateIdentifier } from "./database/validate.js"; +import { getI18nConfig } from "./i18n/config.js"; +import { repairLocaleCasing } from "./i18n/repair-locale-casing.js"; import { normalizeMediaValue } from "./media/normalize.js"; import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js"; import { @@ -71,6 +73,12 @@ import { createSingleFlightCache, singleFlightCached } from "./utils/single-flig import { COMMIT, VERSION } from "./version.js"; const LEADING_SLASH_PATTERN = /^\//; +const LOCALE_CASING_REPAIR_OPTION = "emdash:repair_locale_casing"; + +function getLocaleCasingRepairVersion(locales: readonly string[]): string | null { + if (locales.length === 0) return null; + return `1:${locales.toSorted().join(",")}`; +} /** * Parse a JSON column expected to contain an array of strings. @@ -1130,6 +1138,10 @@ export class EmDashRuntime { const storage = EmDashRuntime.getStorage(deps); let pluginStates: Map = new Map(); + const configuredLocales: string[] = + virtualConfig?.i18n?.locales ?? getI18nConfig()?.locales ?? []; + const localeCasingRepairVersion = getLocaleCasingRepairVersion(configuredLocales); + let storedLocaleCasingRepairVersion: string | undefined; let siteInfo: | { siteName?: string; @@ -1175,7 +1187,9 @@ export class EmDashRuntime { "emdash:site_title", "emdash:site_url", "emdash:locale", + LOCALE_CASING_REPAIR_OPTION, ]); + storedLocaleCasingRepairVersion = siteOpts.get(LOCALE_CASING_REPAIR_OPTION); return { siteName: siteOpts.get("emdash:site_title") ?? undefined, siteUrl: siteOpts.get("emdash:site_url") ?? undefined, @@ -1241,6 +1255,20 @@ export class EmDashRuntime { await Promise.all(coldStartReads); + if ( + localeCasingRepairVersion && + (configuredLocales.some( + (locale) => locale.includes("-") || locale !== locale.toLowerCase(), + ) || + storedLocaleCasingRepairVersion !== undefined) && + storedLocaleCasingRepairVersion !== localeCasingRepairVersion + ) { + await phase("rt.locale", "Repair locale casing", async () => { + await repairLocaleCasing(db, configuredLocales); + await new OptionsRepository(db).set(LOCALE_CASING_REPAIR_OPTION, localeCasingRepairVersion); + }); + } + // Auto-seed the default schema for a first load that skipped the setup // wizard (the wizard and dev-bypass apply seeds explicitly). Run under a // per-isolate lock keyed by the configured db so a reclaimed-and-rerun diff --git a/packages/core/src/i18n/config.ts b/packages/core/src/i18n/config.ts index 6732cc394b..c41232a76e 100644 --- a/packages/core/src/i18n/config.ts +++ b/packages/core/src/i18n/config.ts @@ -30,6 +30,14 @@ export function getI18nConfig(): I18nConfig | null { return _config ?? null; } +/** Match a locale to the exact casing used by the site configuration. */ +export function resolveConfiguredLocale(locale: string): string { + return ( + _config?.locales.find((configured) => configured.toLowerCase() === locale.toLowerCase()) ?? + locale + ); +} + /** * Check if i18n is enabled. * Returns true when multiple locales are configured. diff --git a/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts b/packages/core/src/i18n/repair-locale-casing.ts similarity index 61% rename from packages/core/src/database/migrations/054_canonicalize_locale_casing.ts rename to packages/core/src/i18n/repair-locale-casing.ts index 1191b854c4..c8815a7deb 100644 --- a/packages/core/src/database/migrations/054_canonicalize_locale_casing.ts +++ b/packages/core/src/i18n/repair-locale-casing.ts @@ -1,20 +1,20 @@ import type { Kysely } from "kysely"; import { sql } from "kysely"; -import { getI18nConfig } from "../../i18n/config.js"; -import { listTablesLike } from "../dialect-helpers.js"; - -/** Canonicalize stored locales to the casing used by the site configuration. */ -export async function up(db: Kysely): Promise { - const locales = getI18nConfig()?.locales ?? []; - if (locales.length === 0) return; +import { listTablesLike } from "../database/dialect-helpers.js"; +import type { Database } from "../database/types.js"; +/** Rewrite stored locales to the exact casing used by the site configuration. */ +export async function repairLocaleCasing( + db: Kysely, + configuredLocales: readonly string[], +): Promise { const tableNames = await listTablesLike(db, "ec_%"); for (const tableName of tableNames) { const table = sql.ref(tableName); const slug = tableName.slice("ec_".length); - for (const locale of locales) { + for (const locale of configuredLocales) { await sql` UPDATE ${table} AS target SET locale = ${locale} @@ -25,6 +25,13 @@ export async function up(db: Kysely): Promise { FROM ${table} AS existing WHERE existing.slug = target.slug AND existing.locale = ${locale} ) + AND target.id = ( + SELECT MIN(candidate.id) + FROM ${table} AS candidate + WHERE candidate.slug = target.slug + AND lower(candidate.locale) = lower(${locale}) + AND candidate.locale != ${locale} + ) `.execute(db); await sql` @@ -42,7 +49,3 @@ export async function up(db: Kysely): Promise { } } } - -export async function down(_db: Kysely): Promise { - // Not reversible: the original casing is not recoverable. -} diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 266b8e5a24..6c1da34284 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -19,7 +19,7 @@ import { TaxonomyRepository } from "../database/repositories/taxonomy.js"; import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; import type { MediaValue } from "../fields/types.js"; -import { getI18nConfig } from "../i18n/config.js"; +import { getI18nConfig, resolveConfiguredLocale } from "../i18n/config.js"; import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js"; import { markContentMediaUsageCollectionStaleSafely } from "../media/usage/content-refresh.js"; import { SchemaRegistry } from "../schema/registry.js"; @@ -263,7 +263,7 @@ export async function applySeed( const termSeedIdMap = new Map(); for (const taxonomy of seed.taxonomies) { - const defLocale = taxonomy.locale ?? defaultLocale; + const defLocale = resolveConfiguredLocale(taxonomy.locale ?? defaultLocale); // (name, locale) is the UNIQUE key after migration 036. const existingDef = await db @@ -340,7 +340,7 @@ export async function applySeed( ); } else { for (const term of taxonomy.terms) { - const termLocale = term.locale ?? defLocale; + const termLocale = resolveConfiguredLocale(term.locale ?? defLocale); const existing = await termRepo.findBySlug(taxonomy.name, term.slug, termLocale); if (existing) { if (onConflict === "error") { @@ -464,7 +464,7 @@ export async function applySeed( // Resolve the entry's locale up front so a non-`en` single-locale // export (which omits `locale`) is filed under the project default // rather than `en` (#1421). - const entryLocale = entry.locale ?? defaultLocale; + const entryLocale = resolveConfiguredLocale(entry.locale ?? defaultLocale); // Check if entry exists (by slug + locale for locale-aware lookup) const existing = await contentRepo.findBySlug(collectionSlug, entry.slug, entryLocale); @@ -621,7 +621,7 @@ export async function applySeed( const itemSeedIdMap = new Map(); for (const menu of seed.menus) { - const locale = menu.locale ?? defaultLocale; + const locale = resolveConfiguredLocale(menu.locale ?? defaultLocale); let lookup = db .selectFrom("_emdash_menus") .selectAll() @@ -867,6 +867,8 @@ async function applyHierarchicalTerms( ): Promise { // "locale::slug" -> id, so the same slug can resolve per locale. const slugToId = new Map(); + const resolveTermLocale = (term: SeedTaxonomyTerm) => + resolveConfiguredLocale(term.locale ?? defLocale); // Multiple passes — handles deep nesting and translationOf forward refs. let remaining = [...terms]; @@ -876,7 +878,7 @@ async function applyHierarchicalTerms( const processedThisPass: string[] = []; for (const term of remaining) { - const termLocale = term.locale ?? defLocale; + const termLocale = resolveTermLocale(term); const parentReady = !term.parent || slugToId.has(`${termLocale}::${term.parent}`); const translationReady = !term.translationOf || termSeedIdMap.has(term.translationOf); @@ -921,7 +923,7 @@ async function applyHierarchicalTerms( } remaining = remaining.filter( - (t) => !processedThisPass.includes(t.slug + "::" + (t.locale ?? defLocale)), + (term) => !processedThisPass.includes(term.slug + "::" + resolveTermLocale(term)), ); maxPasses--; } diff --git a/packages/core/src/seed/validate.ts b/packages/core/src/seed/validate.ts index 49d021f18b..d4396d6c14 100644 --- a/packages/core/src/seed/validate.ts +++ b/packages/core/src/seed/validate.ts @@ -4,6 +4,7 @@ * Validates a seed file structure before applying it. */ +import { getI18nConfig, resolveConfiguredLocale } from "../i18n/config.js"; import { FIELD_TYPES } from "../schema/types.js"; import type { SeedFile, SeedMenuItem, ValidationResult } from "./types.js"; @@ -49,6 +50,9 @@ export function validateSeed(data: unknown): ValidationResult { } const seed = data as Partial; + const defaultLocale = + getI18nConfig()?.defaultLocale ?? + (typeof seed.defaultLocale === "string" ? seed.defaultLocale : "en"); // Required fields if (!seed.version) { @@ -163,7 +167,8 @@ export function validateSeed(data: unknown): ValidationResult { errors.push(`${prefix}: name is required`); } else { // Uniqueness is per (name, locale). - const key = `${taxonomy.name}::${taxonomy.locale ?? ""}`; + const locale = resolveConfiguredLocale(taxonomy.locale ?? defaultLocale); + const key = `${taxonomy.name}::${locale}`; if (taxonomyNames.has(key)) { errors.push( taxonomy.locale @@ -206,7 +211,10 @@ export function validateSeed(data: unknown): ValidationResult { } else { // Uniqueness is per (slug, locale) so the same slug can repeat // across locale variants of the def. - const key = `${term.slug}::${term.locale ?? taxonomy.locale ?? ""}`; + const locale = resolveConfiguredLocale( + term.locale ?? taxonomy.locale ?? defaultLocale, + ); + const key = `${term.slug}::${locale}`; if (termSlugs.has(key)) { errors.push( `${termPrefix}.slug: duplicate term slug "${term.slug}" in taxonomy "${taxonomy.name}"`, @@ -233,7 +241,9 @@ export function validateSeed(data: unknown): ValidationResult { if (taxonomy.hierarchical && taxonomy.terms) { for (let j = 0; j < taxonomy.terms.length; j++) { const term = taxonomy.terms[j]; - const termLocale = term.locale ?? taxonomy.locale ?? ""; + const termLocale = resolveConfiguredLocale( + term.locale ?? taxonomy.locale ?? defaultLocale, + ); if (term.parent && !termSlugs.has(`${term.parent}::${termLocale}`)) { errors.push( `${prefix}.terms[${j}].parent: parent term "${term.parent}" not found in taxonomy`, @@ -268,7 +278,8 @@ export function validateSeed(data: unknown): ValidationResult { } else { // Uniqueness is per (name, locale) — siblings of a translation // group share name but differ in locale. - const key = `${menu.name}::${menu.locale ?? ""}`; + const locale = resolveConfiguredLocale(menu.locale ?? defaultLocale); + const key = `${menu.name}::${locale}`; if (menuNames.has(key)) { errors.push( menu.locale diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 53a9835e61..6767b491d5 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -140,7 +140,6 @@ describe("Database Migrations (Integration)", () => { "051_content_taxonomies_denorm", "052_media_usage_read_index", "053_plugin_mcp_tools", - "054_canonicalize_locale_casing", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/integration/runtime/create.test.ts b/packages/core/tests/integration/runtime/create.test.ts index 65b249174f..c3927902ff 100644 --- a/packages/core/tests/integration/runtime/create.test.ts +++ b/packages/core/tests/integration/runtime/create.test.ts @@ -20,6 +20,7 @@ import { OptionsRepository } from "../../../src/database/repositories/options.js import type { Database as EmDashDatabase } from "../../../src/database/types.js"; import { EmDashRuntime } from "../../../src/emdash-runtime.js"; import type { RuntimeDependencies } from "../../../src/emdash-runtime.js"; +import { getI18nConfig, setI18nConfig } from "../../../src/i18n/config.js"; import { definePlugin } from "../../../src/plugins/define-plugin.js"; import type { ContentBeforeSaveHandler } from "../../../src/plugins/types.js"; import { runWithContext } from "../../../src/request-context.js"; @@ -115,6 +116,86 @@ describe("EmDashRuntime.create — cold boot", () => { } }); + it("repairs historical locale casing from runtime configuration", async () => { + const previousI18nConfig = getI18nConfig(); + setI18nConfig({ defaultLocale: "zh-TW", locales: ["en", "zh-TW"] }); + const sqlite = new Database(":memory:"); + const setupDb = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + await runMigrations(setupDb); + await sql` + CREATE TABLE ec_post ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL, + locale TEXT NOT NULL, + UNIQUE (slug, locale) + ) + `.execute(setupDb); + await sql`INSERT INTO ec_post (id, slug, locale) VALUES ('post-1', 'guide', 'zh-tw')`.execute( + setupDb, + ); + await new OptionsRepository(setupDb).set("emdash:setup_complete", true); + + const deps = createDeps(); + deps.createDialect = () => new SqliteDialect({ database: sqlite }); + const timings: Array<{ name: string; dur: number; desc?: string }> = []; + const runtime = await EmDashRuntime.create(deps, timings); + + try { + const row = await sql<{ locale: string }>`SELECT locale FROM ec_post`.execute(runtime.db); + expect(row.rows[0]?.locale).toBe("zh-TW"); + expect(await new OptionsRepository(runtime.db).get("emdash:repair_locale_casing")).toBe( + "1:en,zh-TW", + ); + expect(timings.map((timing) => timing.name)).toContain("rt.locale"); + } finally { + await runtime.stopCron(); + await setupDb.destroy(); + setI18nConfig(previousI18nConfig); + } + }); + + it("repairs casing after configuration changes to a simple lowercase locale", async () => { + const previousI18nConfig = getI18nConfig(); + setI18nConfig({ defaultLocale: "en", locales: ["en"] }); + const sqlite = new Database(":memory:"); + const setupDb = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + await runMigrations(setupDb); + await sql` + CREATE TABLE ec_post ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL, + locale TEXT NOT NULL, + UNIQUE (slug, locale) + ) + `.execute(setupDb); + await sql`INSERT INTO ec_post (id, slug, locale) VALUES ('post-1', 'guide', 'EN')`.execute( + setupDb, + ); + const options = new OptionsRepository(setupDb); + await options.set("emdash:setup_complete", true); + await options.set("emdash:repair_locale_casing", "1:EN"); + + const deps = createDeps(); + deps.createDialect = () => new SqliteDialect({ database: sqlite }); + const runtime = await EmDashRuntime.create(deps); + + try { + const row = await sql<{ locale: string }>`SELECT locale FROM ec_post`.execute(runtime.db); + expect(row.rows[0]?.locale).toBe("en"); + expect(await new OptionsRepository(runtime.db).get("emdash:repair_locale_casing")).toBe( + "1:en", + ); + } finally { + await runtime.stopCron(); + await setupDb.destroy(); + setI18nConfig(previousI18nConfig); + } + }); + it("passes normalized site information to the sandbox runner", async () => { const sqlite = new Database(":memory:"); const setupDb = new Kysely({ diff --git a/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts b/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts deleted file mode 100644 index ee70ea30ea..0000000000 --- a/packages/core/tests/unit/database/migrations/054_canonicalize_locale_casing.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { Kysely } from "kysely"; -import { sql } from "kysely"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { up } from "../../../../src/database/migrations/054_canonicalize_locale_casing.js"; -import { ContentRepository } from "../../../../src/database/repositories/content.js"; -import type { Database } from "../../../../src/database/types.js"; -import { getI18nConfig, setI18nConfig } from "../../../../src/i18n/config.js"; -import { createPostFixture } from "../../../utils/fixtures.js"; -import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js"; - -describe("migration 054: canonicalize locale casing", () => { - let db: Kysely; - const previousI18nConfig = getI18nConfig(); - - beforeEach(async () => { - db = await setupTestDatabaseWithCollections(); - }); - - afterEach(async () => { - setI18nConfig(previousI18nConfig); - await teardownTestDatabase(db); - }); - - it("rewrites rows stored in the wrong case to the configured locale's canonical casing", async () => { - setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); - - const repo = new ContentRepository(db); - const post = await repo.create( - createPostFixture({ slug: "guide", status: "published", data: { title: "Guide" } }), - ); - // Simulate a row saved under the lowercased locale. - await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'guide'`.execute(db); - await db - .insertInto("taxonomies") - .values({ - id: "category-news", - name: "category", - slug: "news", - label: "News", - parent_id: null, - data: null, - locale: "en", - translation_group: "category-news", - }) - .execute(); - await db - .insertInto("content_taxonomies") - .values({ - collection: "post", - entry_id: post.id, - taxonomy_id: "category-news", - locale: "zh-tw", - }) - .execute(); - - await up(db); - - const contentRow = await db - .selectFrom("ec_post") - .select("locale") - .where("slug", "=", "guide") - .executeTakeFirstOrThrow(); - const pivotRow = await db - .selectFrom("content_taxonomies") - .select("locale") - .where("entry_id", "=", post.id) - .executeTakeFirstOrThrow(); - expect(contentRow.locale).toBe("zh-TW"); - expect(pivotRow.locale).toBe("zh-TW"); - }); - - it("preserves case-variant rows when canonicalizing would violate slug uniqueness", async () => { - setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); - - const repo = new ContentRepository(db); - await repo.create(createPostFixture({ slug: "guide", locale: "zh-tw" })); - await repo.create(createPostFixture({ slug: "guide", locale: "zh-TW" })); - - await expect(up(db)).resolves.toBeUndefined(); - - const rows = await db - .selectFrom("ec_post") - .select("locale") - .where("slug", "=", "guide") - .orderBy("locale") - .execute(); - expect(rows.map((row) => row.locale)).toEqual(["zh-TW", "zh-tw"]); - }); - - it("leaves rows already in canonical casing untouched", async () => { - setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); - - const repo = new ContentRepository(db); - await repo.create( - createPostFixture({ - slug: "already-correct", - status: "published", - data: { title: "Correct" }, - locale: "zh-TW", - }), - ); - - await up(db); - - const row = await db - .selectFrom("ec_post") - .select("locale") - .where("slug", "=", "already-correct") - .executeTakeFirstOrThrow(); - expect(row.locale).toBe("zh-TW"); - }); - - it("does nothing when i18n is not configured", async () => { - setI18nConfig(null); - - const repo = new ContentRepository(db); - await repo.create( - createPostFixture({ slug: "no-i18n", status: "published", data: { title: "No i18n" } }), - ); - await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'no-i18n'`.execute(db); - - await up(db); - - const row = await db - .selectFrom("ec_post") - .select("locale") - .where("slug", "=", "no-i18n") - .executeTakeFirstOrThrow(); - expect(row.locale).toBe("zh-tw"); - }); -}); diff --git a/packages/core/tests/unit/i18n/repair-locale-casing.test.ts b/packages/core/tests/unit/i18n/repair-locale-casing.test.ts new file mode 100644 index 0000000000..01e06787d8 --- /dev/null +++ b/packages/core/tests/unit/i18n/repair-locale-casing.test.ts @@ -0,0 +1,290 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + handleContentCreate, + handleContentGet, + handleContentList, +} from "../../../src/api/handlers/content.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import type { Database } from "../../../src/database/types.js"; +import { getI18nConfig, setI18nConfig } from "../../../src/i18n/config.js"; +import { repairLocaleCasing } from "../../../src/i18n/repair-locale-casing.js"; +import { applySeed } from "../../../src/seed/apply.js"; +import type { SeedFile } from "../../../src/seed/types.js"; +import { validateSeed } from "../../../src/seed/validate.js"; +import { createPostFixture } from "../../utils/fixtures.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +describe("repairLocaleCasing", () => { + let db: Kysely; + const previousI18nConfig = getI18nConfig(); + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + }); + + afterEach(async () => { + setI18nConfig(previousI18nConfig); + await teardownTestDatabase(db); + }); + + it("rewrites content and taxonomy pivots to configured locale casing", async () => { + const repo = new ContentRepository(db); + const post = await repo.create( + createPostFixture({ slug: "guide", status: "published", data: { title: "Guide" } }), + ); + await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'guide'`.execute(db); + await db + .insertInto("taxonomies") + .values({ + id: "category-news", + name: "category", + slug: "news", + label: "News", + parent_id: null, + data: null, + locale: "en", + translation_group: "category-news", + }) + .execute(); + await db + .insertInto("content_taxonomies") + .values({ + collection: "post", + entry_id: post.id, + taxonomy_id: "category-news", + locale: "zh-tw", + }) + .execute(); + + await repairLocaleCasing(db, ["en", "zh-TW"]); + + const contentRow = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "guide") + .executeTakeFirstOrThrow(); + const pivotRow = await db + .selectFrom("content_taxonomies") + .select("locale") + .where("entry_id", "=", post.id) + .executeTakeFirstOrThrow(); + expect(contentRow.locale).toBe("zh-TW"); + expect(pivotRow.locale).toBe("zh-TW"); + }); + + it("preserves case-variant rows when canonicalizing would violate slug uniqueness", async () => { + const repo = new ContentRepository(db); + await repo.create(createPostFixture({ slug: "guide", locale: "zh-tw" })); + await repo.create(createPostFixture({ slug: "guide", locale: "zh-TW" })); + + await expect(repairLocaleCasing(db, ["en", "zh-TW"])).resolves.toBeUndefined(); + + const rows = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "guide") + .orderBy("locale") + .execute(); + expect(rows.map((row) => row.locale)).toEqual(["zh-TW", "zh-tw"]); + }); + + it("uses lowercase configured casing as authoritative", async () => { + const repo = new ContentRepository(db); + await repo.create(createPostFixture({ slug: "guide", locale: "zh-TW" })); + + await repairLocaleCasing(db, ["en", "zh-tw"]); + + const row = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "guide") + .executeTakeFirstOrThrow(); + expect(row.locale).toBe("zh-tw"); + }); + + it("canonicalizes explicit content writes to configured casing", async () => { + setI18nConfig({ defaultLocale: "zh-TW", locales: ["en", "zh-TW"] }); + + const result = await handleContentCreate(db, "post", { + data: { title: "Guide" }, + locale: "zh-tw", + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.item.locale).toBe("zh-TW"); + + const list = await handleContentList(db, "post", { locale: "zh-tw" }); + expect(list.success).toBe(true); + if (!list.success) return; + expect(list.data.items.map((item) => item.id)).toContain(result.data.item.id); + + const get = await handleContentGet(db, "post", "guide", "zh-tw"); + expect(get.success).toBe(true); + if (!get.success) return; + expect(get.data.item.id).toBe(result.data.item.id); + }); + + it("canonicalizes explicit seed locales to configured casing", async () => { + setI18nConfig({ defaultLocale: "zh-TW", locales: ["en", "zh-TW"] }); + const seed: SeedFile = { + version: "1", + content: { + post: [ + { + id: "post-1", + slug: "guide", + locale: "zh-tw", + data: { title: "Guide" }, + }, + ], + }, + }; + + await applySeed(db, seed, { includeContent: true }); + + const row = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "guide") + .executeTakeFirstOrThrow(); + expect(row.locale).toBe("zh-TW"); + }); + + it("canonicalizes explicit flat taxonomy term locales", async () => { + setI18nConfig({ defaultLocale: "zh-TW", locales: ["en", "zh-TW"] }); + const seed: SeedFile = { + version: "1", + taxonomies: [ + { + name: "topic", + label: "Topics", + hierarchical: false, + collections: ["post"], + locale: "zh-TW", + terms: [{ slug: "news", label: "News", locale: "zh-tw" }], + }, + ], + }; + + await applySeed(db, seed, { includeContent: true }); + + const row = await db + .selectFrom("taxonomies") + .select("locale") + .where("name", "=", "topic") + .where("slug", "=", "news") + .executeTakeFirstOrThrow(); + expect(row.locale).toBe("zh-TW"); + }); + + it("validates term uniqueness using configured locale casing", () => { + setI18nConfig({ defaultLocale: "zh-TW", locales: ["en", "zh-TW"] }); + const seed: SeedFile = { + version: "1", + taxonomies: [ + { + name: "topic", + label: "Topics", + hierarchical: false, + collections: ["post"], + locale: "zh-TW", + terms: [ + { slug: "news", label: "News" }, + { slug: "news", label: "News duplicate", locale: "zh-tw" }, + ], + }, + ], + }; + + const validation = validateSeed(seed); + + expect(validation.valid).toBe(false); + expect(validation.errors.some((error) => error.includes("duplicate term slug"))).toBe(true); + }); + + it("validates taxonomy and menu uniqueness using configured locale casing", () => { + setI18nConfig({ defaultLocale: "zh-TW", locales: ["en", "zh-TW"] }); + const seed: SeedFile = { + version: "1", + taxonomies: [ + { + name: "topic", + label: "Topics", + hierarchical: false, + collections: ["post"], + }, + { + name: "topic", + label: "Topics duplicate", + hierarchical: false, + collections: ["post"], + locale: "zh-tw", + }, + ], + menus: [ + { name: "primary", label: "Primary", items: [] }, + { name: "primary", label: "Primary duplicate", locale: "zh-tw", items: [] }, + ], + }; + + const validation = validateSeed(seed); + + expect(validation.valid).toBe(false); + expect(validation.errors.some((error) => error.includes("duplicate taxonomy"))).toBe(true); + expect(validation.errors.some((error) => error.includes("duplicate menu"))).toBe(true); + }); + + it("canonicalizes explicit hierarchical taxonomy term locales", async () => { + setI18nConfig({ defaultLocale: "zh-TW", locales: ["en", "zh-TW"] }); + const seed: SeedFile = { + version: "1", + taxonomies: [ + { + name: "section", + label: "Sections", + hierarchical: true, + collections: ["post"], + locale: "zh-TW", + terms: [ + { slug: "parent", label: "Parent" }, + { slug: "child", label: "Child", parent: "parent", locale: "zh-tw" }, + ], + }, + ], + }; + + const validation = validateSeed(seed); + expect(validation.valid).toBe(true); + await applySeed(db, seed, { includeContent: true }); + + const rows = await db + .selectFrom("taxonomies") + .select("locale") + .where("name", "=", "section") + .execute(); + expect(rows).toHaveLength(2); + expect(rows.every((row) => row.locale === "zh-TW")).toBe(true); + }); + + it("updates only one row when multiple noncanonical variants would collide", async () => { + const repo = new ContentRepository(db); + await repo.create(createPostFixture({ slug: "guide", locale: "zh-tw" })); + await repo.create(createPostFixture({ slug: "guide", locale: "ZH-tw" })); + + await expect(repairLocaleCasing(db, ["en", "zh-TW"])).resolves.toBeUndefined(); + + const rows = await db + .selectFrom("ec_post") + .select("locale") + .where("slug", "=", "guide") + .orderBy("locale") + .execute(); + const locales = rows.map((row) => row.locale); + expect(locales).toContain("zh-TW"); + expect(locales.filter((locale) => locale !== "zh-TW")).toHaveLength(1); + }); +}); From 469b20694a362f3c50059a72ddbd7d2f6d9ffa0d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 21 Jul 2026 18:49:28 +0100 Subject: [PATCH 7/9] fix(core): canonicalize localized API writes --- .changeset/localecode-preserve-casing.md | 2 +- packages/core/src/api/handlers/bylines.ts | 9 +++++---- packages/core/src/api/handlers/menus.ts | 17 +++++++++++------ packages/core/src/api/handlers/relations.ts | 9 +++++++-- packages/core/src/api/handlers/taxonomies.ts | 19 +++++++++++-------- packages/core/src/api/schemas/bylines.ts | 8 ++++---- packages/core/src/api/schemas/menus.ts | 3 ++- packages/core/src/api/schemas/relations.ts | 4 +++- packages/core/src/api/schemas/taxonomies.ts | 6 ++++-- .../astro/routes/api/admin/bylines/index.ts | 13 +++++-------- .../integration/api/menus-handlers.test.ts | 14 ++++++++++++++ .../api/relations-handlers.test.ts | 10 ++++++++++ .../content/content-taxonomies.test.ts | 5 +---- .../taxonomies/taxonomy-locale-terms.test.ts | 16 ++++++++++++++++ .../tests/unit/api/handlers/bylines.test.ts | 15 +++++++++++++++ 15 files changed, 109 insertions(+), 41 deletions(-) diff --git a/.changeset/localecode-preserve-casing.md b/.changeset/localecode-preserve-casing.md index fb13dbfe74..4db93111a2 100644 --- a/.changeset/localecode-preserve-casing.md +++ b/.changeset/localecode-preserve-casing.md @@ -2,4 +2,4 @@ "emdash": patch --- -Fixes locale-filtered content, search, taxonomy, and menu results for locales with uppercase region or script subtags, including existing content saved with lowercased locale values. +Fixes locale-filtered content, search, taxonomy, and menu results for locales with uppercase region or script subtags. Existing content rows saved with lowercased locale values are repaired automatically. diff --git a/packages/core/src/api/handlers/bylines.ts b/packages/core/src/api/handlers/bylines.ts index 30e9eb4597..71b5e7afb8 100644 --- a/packages/core/src/api/handlers/bylines.ts +++ b/packages/core/src/api/handlers/bylines.ts @@ -7,7 +7,7 @@ import { } from "../../database/repositories/byline.js"; import { EmDashValidationError, type BylineSummary } from "../../database/repositories/types.js"; import type { Database } from "../../database/types.js"; -import { getI18nConfig } from "../../i18n/config.js"; +import { getI18nConfig, resolveConfiguredLocale } from "../../i18n/config.js"; import type { ApiResult } from "../types.js"; // `undefined → null` so a missing field in the create payload matches the @@ -138,7 +138,8 @@ export async function handleBylineCreate( }; } - const localeErr = rejectUnknownLocale(input.locale); + const locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined; + const localeErr = rejectUnknownLocale(locale); if (localeErr) return localeErr; const repo = new BylineRepository(db); @@ -160,7 +161,7 @@ export async function handleBylineCreate( sourceGroup = source.translationGroup ?? source.id; } - const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en"; + const effectiveLocale = locale ?? getI18nConfig()?.defaultLocale ?? "en"; // Translation-group guard: the row-per-locale model (PR #916) // allows exactly one row per (translation_group, locale). Reject @@ -216,7 +217,7 @@ export async function handleBylineCreate( }; } - const byline = await repo.create(input); + const byline = await repo.create({ ...input, locale: effectiveLocale }); return { success: true, data: byline }; } catch (error) { // Mirror handleBylineUpdate: surface customFields validation diff --git a/packages/core/src/api/handlers/menus.ts b/packages/core/src/api/handlers/menus.ts index 9c3bda84e7..e9af79c696 100644 --- a/packages/core/src/api/handlers/menus.ts +++ b/packages/core/src/api/handlers/menus.ts @@ -25,7 +25,7 @@ import { type UpdateMenuItemInput as UpdateMenuItemRepoInput, } from "../../database/repositories/menu.js"; import type { Database } from "../../database/types.js"; -import { getI18nConfig } from "../../i18n/config.js"; +import { getI18nConfig, resolveConfiguredLocale } from "../../i18n/config.js"; import type { ApiResult } from "../types.js"; // Re-export entity types so route files and tests can import them from the @@ -89,7 +89,8 @@ async function resolveMenu( name: string, options: { locale?: string }, ): Promise { - const matches = await repo.findByName(name, options); + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; + const matches = await repo.findByName(name, { locale }); if (matches.length === 0) { return { success: false, @@ -124,7 +125,8 @@ export async function handleMenuList( ): Promise> { try { const repo = new MenuRepository(db); - const items = await repo.findMany(options); + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; + const items = await repo.findMany({ locale }); return { success: true, data: items }; } catch { return { @@ -175,7 +177,9 @@ export async function handleMenuCreate( // Duplicate guard: same (name, locale). Falls back to the configured // defaultLocale to match the column DEFAULT set by migration 036. - const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en"; + const effectiveLocale = input.locale + ? resolveConfiguredLocale(input.locale) + : (getI18nConfig()?.defaultLocale ?? "en"); if (await repo.existsByNameAndLocale(input.name, effectiveLocale)) { return { success: false, @@ -188,7 +192,7 @@ export async function handleMenuCreate( }; } - const menu = await repo.create(input); + const menu = await repo.create({ ...input, locale: effectiveLocale }); return { success: true, data: menu }; } catch { return { @@ -212,7 +216,8 @@ export async function handleMenuGet( ): Promise> { try { const repo = new MenuRepository(db); - const matches = await repo.findByName(name, options); + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; + const matches = await repo.findByName(name, { locale }); if (matches.length === 0) { return { success: false, diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index 5640abe511..3581ae1696 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -10,6 +10,7 @@ import { import { InvalidCursorError } from "../../database/repositories/types.js"; import type { ContentItem } from "../../database/repositories/types.js"; import type { Database } from "../../database/types.js"; +import { resolveConfiguredLocale } from "../../i18n/config.js"; import { SchemaRegistry } from "../../schema/registry.js"; import type { ApiResult } from "../types.js"; @@ -70,7 +71,10 @@ export async function handleRelationCreate( } } - const relation = await repo.create(input); + const relation = await repo.create({ + ...input, + locale: input.locale ? resolveConfiguredLocale(input.locale) : undefined, + }); return { success: true, data: { relation } }; } catch (error) { // A bad `translationOf` makes the repo throw loudly rather than mint an @@ -127,7 +131,8 @@ export async function handleRelationList( ): Promise> { try { const repo = new RelationRepository(db); - const relations = await repo.list(opts.locale); + const locale = opts.locale ? resolveConfiguredLocale(opts.locale) : undefined; + const relations = await repo.list(locale); return { success: true, data: { relations } }; } catch { return { diff --git a/packages/core/src/api/handlers/taxonomies.ts b/packages/core/src/api/handlers/taxonomies.ts index 7dc1b7291a..83bd41a2ec 100644 --- a/packages/core/src/api/handlers/taxonomies.ts +++ b/packages/core/src/api/handlers/taxonomies.ts @@ -12,6 +12,7 @@ import { ulid } from "ulidx"; import { TaxonomyRepository } from "../../database/repositories/taxonomy.js"; import type { Database, TaxonomyDefTable } from "../../database/types.js"; +import { resolveConfiguredLocale } from "../../i18n/config.js"; import { invalidateTaxonomyDefsCache, invalidateTermCache } from "../../taxonomies/index.js"; import { fetchVisibleTermCounts } from "../../taxonomies/term-counts.js"; import type { ApiResult } from "../types.js"; @@ -216,6 +217,7 @@ export async function handleTaxonomyCreate( }, ): Promise> { try { + const locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined; if (!NAME_PATTERN.test(input.name)) { return { success: false, @@ -265,19 +267,19 @@ export async function handleTaxonomyCreate( // Duplicate guard scoped to locale (so the same name can exist in ES // and EN). - if (input.locale !== undefined) { + if (locale !== undefined) { const existing = await db .selectFrom("_emdash_taxonomy_defs") .select("id") .where("name", "=", input.name) - .where("locale", "=", input.locale) + .where("locale", "=", locale) .executeTakeFirst(); if (existing) { return { success: false, error: { code: "CONFLICT", - message: `Taxonomy '${input.name}' already exists in locale '${input.locale}'`, + message: `Taxonomy '${input.name}' already exists in locale '${locale}'`, }, }; } @@ -293,7 +295,7 @@ export async function handleTaxonomyCreate( label_singular: input.labelSingular ?? null, hierarchical: input.hierarchical ? 1 : 0, collections: JSON.stringify(collections), - ...(input.locale !== undefined ? { locale: input.locale } : {}), + ...(locale !== undefined ? { locale } : {}), translation_group: translationGroup ?? id, }) .execute(); @@ -535,6 +537,7 @@ export async function handleTermCreate( }, ): Promise> { try { + const locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined; // Taxonomy definitions are per-locale, but terms can exist in any locale // regardless of whether the def has been translated there. Look up the // def across all locales — we only care that it *exists*. @@ -548,14 +551,14 @@ export async function handleTermCreate( input.parentId === "" || input.parentId === undefined ? undefined : input.parentId; // Conflict check is scoped to locale (per-locale slugs are unique). - const existing = await repo.findBySlug(taxonomyName, input.slug, input.locale); + const existing = await repo.findBySlug(taxonomyName, input.slug, locale); if (existing) { return { success: false, error: { code: "CONFLICT", - message: input.locale - ? `Term '${input.slug}' already exists in '${taxonomyName}' (${input.locale})` + message: locale + ? `Term '${input.slug}' already exists in '${taxonomyName}' (${locale})` : `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`, }, }; @@ -594,7 +597,7 @@ export async function handleTermCreate( label: input.label, parentId: parentId ?? undefined, data: input.description ? { description: input.description } : undefined, - locale: input.locale, + locale, translationOf: input.translationOf, }); diff --git a/packages/core/src/api/schemas/bylines.ts b/packages/core/src/api/schemas/bylines.ts index 00f04d77e4..1e6b30259b 100644 --- a/packages/core/src/api/schemas/bylines.ts +++ b/packages/core/src/api/schemas/bylines.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { cursorPaginationQuery, httpUrl } from "./common.js"; +import { cursorPaginationQuery, httpUrl, localeCode } from "./common.js"; /** Slug pattern: lowercase letters, digits, and hyphens; must start with a letter */ const bylineSlugPattern = /^[a-z][a-z0-9-]*$/; @@ -81,7 +81,7 @@ export const bylinesListQuery = cursorPaginationQuery * Rejects empty strings so the picker can't silently fetch the * unfiltered list when the admin URL has `?locale=` with no value. */ - locale: z.string().min(1).optional(), + locale: localeCode.optional(), }) .meta({ id: "BylinesListQuery" }); @@ -102,7 +102,7 @@ export const bylineCreateBody = z * configured `defaultLocale`) is used. Rejects empty strings — an * empty locale would create rows no resolver requests. */ - locale: z.string().min(1).optional(), + locale: localeCode.optional(), /** * When set, the new row joins the source byline's translation_group * rather than minting a fresh one. Requires `locale`. @@ -126,7 +126,7 @@ export const bylineCreateBody = z export const bylineTranslationCreateBody = z .object({ - locale: z.string().min(1), + locale: localeCode, slug: z .string() .min(1) diff --git a/packages/core/src/api/schemas/menus.ts b/packages/core/src/api/schemas/menus.ts index f7d7b046e6..1a6c0e1e37 100644 --- a/packages/core/src/api/schemas/menus.ts +++ b/packages/core/src/api/schemas/menus.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { isSafeHref } from "../../utils/url.js"; +import { localeCode } from "./common.js"; // --------------------------------------------------------------------------- // Menus: Input schemas @@ -24,7 +25,7 @@ export const createMenuBody = z .object({ name: z.string().min(1), label: z.string().min(1), - locale: z.string().min(1).optional(), + locale: localeCode.optional(), /** When set, clones the items from the source menu. The new menu joins * the source's translation_group. */ translationOf: z.string().min(1).optional(), diff --git a/packages/core/src/api/schemas/relations.ts b/packages/core/src/api/schemas/relations.ts index 12bdc9e2d9..13a16948e6 100644 --- a/packages/core/src/api/schemas/relations.ts +++ b/packages/core/src/api/schemas/relations.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { localeCode } from "./common.js"; + const slugPattern = /^[a-z][a-z0-9_]*$/; const collectionSlug = z .string() @@ -18,7 +20,7 @@ export const createRelationBody = z childCollection: collectionSlug.optional(), parentLabel: z.string().min(1).max(200), childLabel: z.string().min(1).max(200), - locale: z.string().min(1).optional(), + locale: localeCode.optional(), translationOf: z.string().min(1).optional(), }) // A translation inherits its structural fields (name, parentCollection, diff --git a/packages/core/src/api/schemas/taxonomies.ts b/packages/core/src/api/schemas/taxonomies.ts index 71e2fff288..da76974cfa 100644 --- a/packages/core/src/api/schemas/taxonomies.ts +++ b/packages/core/src/api/schemas/taxonomies.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { localeCode } from "./common.js"; + // --------------------------------------------------------------------------- // Taxonomy definitions: Input schemas // --------------------------------------------------------------------------- @@ -24,7 +26,7 @@ export const createTaxonomyDefBody = z .max(100) .optional() .default([]), - locale: z.string().min(1).optional(), + locale: localeCode.optional(), translationOf: z.string().min(1).optional(), }) .meta({ id: "CreateTaxonomyDefBody" }); @@ -39,7 +41,7 @@ export const createTermBody = z label: z.string().min(1), parentId: z.string().nullish(), description: z.string().optional(), - locale: z.string().min(1).optional(), + locale: localeCode.optional(), translationOf: z.string().min(1).optional(), }) .meta({ id: "CreateTermBody" }); diff --git a/packages/core/src/astro/routes/api/admin/bylines/index.ts b/packages/core/src/astro/routes/api/admin/bylines/index.ts index 0c81115ecf..cf69c72881 100644 --- a/packages/core/src/astro/routes/api/admin/bylines/index.ts +++ b/packages/core/src/astro/routes/api/admin/bylines/index.ts @@ -8,7 +8,7 @@ import { bylineCreateBody, bylinesListQuery } from "#api/schemas.js"; import { invalidateBylineCache } from "#bylines/index.js"; import { BylineRepository } from "#db/repositories/byline.js"; -import { getI18nConfig } from "../../../../../i18n/config.js"; +import { getI18nConfig, resolveConfiguredLocale } from "../../../../../i18n/config.js"; export const prerender = false; @@ -25,13 +25,10 @@ export const GET: APIRoute = async ({ url, locals }) => { const query = parseQuery(url, bylinesListQuery); if (isParseError(query)) return query; + const locale = query.locale ? resolveConfiguredLocale(query.locale) : undefined; const i18n = getI18nConfig(); - if (query.locale && i18n && !i18n.locales.includes(query.locale)) { - return apiError( - "VALIDATION_ERROR", - `Locale "${query.locale}" is not configured for this site`, - 400, - ); + if (locale && i18n && !i18n.locales.includes(locale)) { + return apiError("VALIDATION_ERROR", `Locale "${locale}" is not configured for this site`, 400); } try { @@ -40,7 +37,7 @@ export const GET: APIRoute = async ({ url, locals }) => { search: query.search, isGuest: query.isGuest, userId: query.userId, - locale: query.locale, + locale, cursor: query.cursor, limit: query.limit, }); diff --git a/packages/core/tests/integration/api/menus-handlers.test.ts b/packages/core/tests/integration/api/menus-handlers.test.ts index 7ea13f9963..2f7ea8dfe0 100644 --- a/packages/core/tests/integration/api/menus-handlers.test.ts +++ b/packages/core/tests/integration/api/menus-handlers.test.ts @@ -21,6 +21,7 @@ import { } from "../../../src/api/handlers/menus.js"; import { createMenuItemBody, updateMenuItemBody } from "../../../src/api/schemas/menus.js"; import type { Database } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; describe("menu schemas", () => { @@ -100,9 +101,22 @@ describe("menu handlers — camelCase responses & customUrl persistence", () => }); afterEach(async () => { + setI18nConfig(null); await teardownTestDatabase(db); }); + it("stores menu locales with the configured casing", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + const result = await handleMenuCreate(db, { + name: "primary", + label: "Primary", + locale: "zh-tw", + }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.locale).toBe("zh-TW"); + }); + it("handleMenuCreate returns a camelCase Menu", async () => { const result = await handleMenuCreate(db, { name: "primary", label: "Primary" }); expect(result.success).toBe(true); diff --git a/packages/core/tests/integration/api/relations-handlers.test.ts b/packages/core/tests/integration/api/relations-handlers.test.ts index 7fcaf7ac85..6c7f323e97 100644 --- a/packages/core/tests/integration/api/relations-handlers.test.ts +++ b/packages/core/tests/integration/api/relations-handlers.test.ts @@ -15,6 +15,7 @@ import { GET as listRelations, POST as createRelation, } from "../../../src/astro/routes/api/relations/index.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; import { describeEachDialect, setupForDialectWithCollections, @@ -40,9 +41,18 @@ describeEachDialect("relations definition handlers", (dialect) => { ctx = await setupForDialectWithCollections(dialect); }); afterEach(async () => { + setI18nConfig(null); await teardownForDialect(ctx); }); + it("stores relation locales with the configured casing", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + const result = await handleRelationCreate(ctx.db, { ...baseInput, locale: "zh-tw" }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.relation.locale).toBe("zh-TW"); + }); + it("create returns the new relation; get fetches it by id", async () => { const created = await handleRelationCreate(ctx.db, { ...baseInput }); expect(created.success).toBe(true); diff --git a/packages/core/tests/integration/content/content-taxonomies.test.ts b/packages/core/tests/integration/content/content-taxonomies.test.ts index 7591def67c..c9e7187be8 100644 --- a/packages/core/tests/integration/content/content-taxonomies.test.ts +++ b/packages/core/tests/integration/content/content-taxonomies.test.ts @@ -177,10 +177,7 @@ describeEachDialect("Content taxonomies field - create/update", (dialect) => { it("resolves slugs in the entry's locale for i18n sites", async () => { setI18nConfig({ defaultLocale: "en", - locales: [ - { code: "en", label: "English" }, - { code: "fr", label: "Français" }, - ], + locales: ["en", "fr"], }); const taxRepo = new TaxonomyRepository(ctx.db); diff --git a/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts b/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts index 913b4fe41d..4f3f35b21b 100644 --- a/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts +++ b/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts @@ -32,6 +32,7 @@ import { up as up045 } from "../../../src/database/migrations/045_taxonomy_paren import { ContentRepository } from "../../../src/database/repositories/content.js"; import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; import type { Database } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; import { describeEachDialect, setupForDialectWithCollections, @@ -165,9 +166,24 @@ describeEachDialect("content terms route locale-awareness (#1218)", (dialect) => }); afterEach(async () => { + setI18nConfig(null); await teardownForDialect(ctx); }); + it("stores term locales with the configured casing", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + await insertHierarchicalDef(ctx.db, "categories"); + const term = await unwrap( + handleTermCreate(ctx.db, "categories", { + slug: "news", + label: "News", + locale: "zh-tw", + }), + ); + + expect(term.locale).toBe("zh-TW"); + }); + it("repository resolves only the entry-locale variant when locale is given", async () => { const fx = await seedLocalizedTags(ctx.db); const taxRepo = new TaxonomyRepository(ctx.db); diff --git a/packages/core/tests/unit/api/handlers/bylines.test.ts b/packages/core/tests/unit/api/handlers/bylines.test.ts index 242fa709fa..872369f513 100644 --- a/packages/core/tests/unit/api/handlers/bylines.test.ts +++ b/packages/core/tests/unit/api/handlers/bylines.test.ts @@ -251,6 +251,21 @@ describe("byline handlers", () => { } }); + it("stores configured locales with their canonical casing", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + try { + const result = await handleBylineCreate(db, { + slug: "jane", + displayName: "Jane", + locale: "zh-tw", + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.locale).toBe("zh-TW"); + } finally { + setI18nConfig(null); + } + }); + it("skips locale validation when no i18n config is set", async () => { setI18nConfig(null); const result = await handleBylineCreate(db, { From 50df9a0fa4aecb8cde4139eb21a4baa1347016f0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 21 Jul 2026 19:15:18 +0100 Subject: [PATCH 8/9] fix(core): canonicalize taxonomy locale filters --- .changeset/localecode-preserve-casing.md | 2 +- packages/core/src/api/handlers/taxonomies.ts | 17 ++++--- .../taxonomies/taxonomy-locale-terms.test.ts | 50 +++++++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/.changeset/localecode-preserve-casing.md b/.changeset/localecode-preserve-casing.md index 4db93111a2..60ee13f37a 100644 --- a/.changeset/localecode-preserve-casing.md +++ b/.changeset/localecode-preserve-casing.md @@ -2,4 +2,4 @@ "emdash": patch --- -Fixes locale-filtered content, search, taxonomy, and menu results for locales with uppercase region or script subtags. Existing content rows saved with lowercased locale values are repaired automatically. +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. diff --git a/packages/core/src/api/handlers/taxonomies.ts b/packages/core/src/api/handlers/taxonomies.ts index 83bd41a2ec..7e61a3be15 100644 --- a/packages/core/src/api/handlers/taxonomies.ts +++ b/packages/core/src/api/handlers/taxonomies.ts @@ -175,8 +175,9 @@ export async function handleTaxonomyList( options: { locale?: string } = {}, ): Promise> { try { + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; let query = db.selectFrom("_emdash_taxonomy_defs").selectAll(); - if (options.locale !== undefined) query = query.where("locale", "=", options.locale); + if (locale !== undefined) query = query.where("locale", "=", locale); const [rows, collectionRows] = await Promise.all([ query.execute(), db.selectFrom("_emdash_collections").select("slug").execute(), @@ -392,7 +393,8 @@ export async function handleTermList( if (!lookup.success) return lookup; const repo = new TaxonomyRepository(db); - const terms = await repo.findByName(taxonomyName, { locale: options.locale }); + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; + const terms = await repo.findByName(taxonomyName, { locale }); // Counts match what visitors see on the public site: published (or // scheduled-and-due) entries that aren't soft-deleted, scoped to the @@ -638,7 +640,8 @@ export async function handleTermGet( ): Promise> { try { const repo = new TaxonomyRepository(db); - const term = await repo.findBySlug(taxonomyName, termSlug, options.locale); + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; + const term = await repo.findBySlug(taxonomyName, termSlug, locale); if (!term) { return { @@ -746,7 +749,8 @@ export async function handleTermUpdate( ): Promise> { try { const repo = new TaxonomyRepository(db); - const term = await repo.findBySlug(taxonomyName, termSlug, options.locale); + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; + const term = await repo.findBySlug(taxonomyName, termSlug, locale); if (!term) { return { @@ -766,7 +770,7 @@ export async function handleTermUpdate( // Check if new slug conflicts (per-locale uniqueness). if (newSlug !== undefined && newSlug !== termSlug) { - const existing = await repo.findBySlug(taxonomyName, newSlug, options.locale); + const existing = await repo.findBySlug(taxonomyName, newSlug, locale); if (existing && existing.id !== term.id) { return { success: false, @@ -835,7 +839,8 @@ export async function handleTermDelete( ): Promise> { try { const repo = new TaxonomyRepository(db); - const term = await repo.findBySlug(taxonomyName, termSlug, options.locale); + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; + const term = await repo.findBySlug(taxonomyName, termSlug, locale); if (!term) { return { diff --git a/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts b/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts index 4f3f35b21b..5bba616422 100644 --- a/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts +++ b/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts @@ -20,8 +20,13 @@ import { afterEach, beforeEach, expect, it } from "vitest"; import { handleContentGet } from "../../../src/api/handlers/content.js"; import { + handleTaxonomyCreate, + handleTaxonomyList, handleTermCreate, + handleTermDelete, + handleTermGet, handleTermList, + handleTermUpdate, type TermWithCount, } from "../../../src/api/handlers/taxonomies.js"; import { @@ -184,6 +189,51 @@ describeEachDialect("content terms route locale-awareness (#1218)", (dialect) => expect(term.locale).toBe("zh-TW"); }); + it("resolves taxonomy reads with the configured locale casing", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + const createdDef = await handleTaxonomyCreate(ctx.db, { + name: "categories", + label: "Categories", + locale: "zh-tw", + }); + expect(createdDef.success).toBe(true); + + const definitions = await handleTaxonomyList(ctx.db, { locale: "zh-tw" }); + expect(definitions.success).toBe(true); + if (!definitions.success) throw new Error(definitions.error.message); + expect(definitions.data.taxonomies.map((taxonomy) => taxonomy.name)).toEqual(["categories"]); + + await unwrap( + handleTermCreate(ctx.db, "categories", { + slug: "news", + label: "News", + locale: "zh-tw", + }), + ); + + const listed = await handleTermList(ctx.db, "categories", { locale: "zh-tw" }); + expect(listed.success).toBe(true); + if (!listed.success) throw new Error(listed.error.message); + expect(listed.data.terms.map((term) => term.slug)).toEqual(["news"]); + + const fetched = await handleTermGet(ctx.db, "categories", "news", { locale: "zh-tw" }); + expect(fetched.success).toBe(true); + + const updated = await handleTermUpdate( + ctx.db, + "categories", + "news", + { label: "Latest news" }, + { locale: "zh-tw" }, + ); + expect(updated.success).toBe(true); + + const deleted = await handleTermDelete(ctx.db, "categories", "news", { + locale: "zh-tw", + }); + expect(deleted.success).toBe(true); + }); + it("repository resolves only the entry-locale variant when locale is given", async () => { const fx = await seedLocalizedTags(ctx.db); const taxRepo = new TaxonomyRepository(ctx.db); From 1a6880fd3a2082f0c9190ac5d39fee937832c84f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 21 Jul 2026 19:27:17 +0100 Subject: [PATCH 9/9] fix(core): canonicalize search locale filters --- packages/core/src/search/query.ts | 5 ++- .../tests/integration/search/suggest.test.ts | 42 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/core/src/search/query.ts b/packages/core/src/search/query.ts index 8b1f01236d..ed0f090644 100644 --- a/packages/core/src/search/query.ts +++ b/packages/core/src/search/query.ts @@ -10,6 +10,7 @@ import { sql } from "kysely"; import { encodeCursor, decodeCursor, InvalidCursorError } from "../database/repositories/types.js"; import type { Database } from "../database/types.js"; import { validateIdentifier } from "../database/validate.js"; +import { resolveConfiguredLocale } from "../i18n/config.js"; import { getDb } from "../loader.js"; import { FTSManager } from "./fts-manager.js"; import type { @@ -250,7 +251,7 @@ async function searchSingleCollection( const contentTable = ftsManager.getContentTableName(collection); const limit = options.limit ?? 20; const status = options.status ?? "published"; - const locale = options.locale; + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; // Check if FTS table exists if (!(await ftsManager.ftsTableExists(collection))) { @@ -397,7 +398,7 @@ export async function getSuggestions( options: SuggestOptions = {}, ): Promise { const limit = options.limit ?? 5; - const locale = options.locale; + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; // Get searchable collections let collections = options.collections; diff --git a/packages/core/tests/integration/search/suggest.test.ts b/packages/core/tests/integration/search/suggest.test.ts index daf85a122e..8d5a61d2ea 100644 --- a/packages/core/tests/integration/search/suggest.test.ts +++ b/packages/core/tests/integration/search/suggest.test.ts @@ -3,9 +3,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import type { Database } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { FTSManager } from "../../../src/search/fts-manager.js"; -import { getSuggestions } from "../../../src/search/query.js"; +import { getSuggestions, searchWithDb } from "../../../src/search/query.js"; import { createPostFixture } from "../../utils/fixtures.js"; import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; @@ -32,6 +33,7 @@ describe("getSuggestions (Integration)", () => { }); afterEach(async () => { + setI18nConfig(null); await teardownTestDatabase(db); }); @@ -72,4 +74,42 @@ describe("getSuggestions (Integration)", () => { expect(suggestions).toEqual([]); }); + + it("searches using the configured locale casing", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + await repo.create( + createPostFixture({ + slug: "taiwan-design", + status: "published", + locale: "zh-TW", + data: { title: "Taiwan design" }, + }), + ); + + const results = await searchWithDb(db, "Taiwan", { + collections: ["post"], + locale: "zh-tw", + }); + + expect(results.items.map((item) => item.slug)).toEqual(["taiwan-design"]); + }); + + it("suggests using the configured locale casing", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); + await repo.create( + createPostFixture({ + slug: "taiwan-design", + status: "published", + locale: "zh-TW", + data: { title: "Taiwan design" }, + }), + ); + + const suggestions = await getSuggestions(db, "Tai", { + collections: ["post"], + locale: "zh-tw", + }); + + expect(suggestions.map((suggestion) => suggestion.slug)).toEqual(["taiwan-design"]); + }); });