diff --git a/.changeset/localecode-preserve-casing.md b/.changeset/localecode-preserve-casing.md new file mode 100644 index 0000000000..60ee13f37a --- /dev/null +++ b/.changeset/localecode-preserve-casing.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +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/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/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/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..7e61a3be15 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"; @@ -174,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(), @@ -216,6 +218,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 +268,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 +296,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(); @@ -390,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 @@ -535,6 +539,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 +553,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 +599,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, }); @@ -635,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 { @@ -743,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 { @@ -763,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, @@ -832,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/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/common.ts b/packages/core/src/api/schemas/common.ts index 59579c698b..3c04483c48 100644 --- a/packages/core/src/api/schemas/common.ts +++ b/packages/core/src/api/schemas/common.ts @@ -53,16 +53,17 @@ 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 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"); /** 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/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/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 919f191be4..5e4a247255 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/i18n/repair-locale-casing.ts b/packages/core/src/i18n/repair-locale-casing.ts new file mode 100644 index 0000000000..c8815a7deb --- /dev/null +++ b/packages/core/src/i18n/repair-locale-casing.ts @@ -0,0 +1,51 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +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 configuredLocales) { + await sql` + UPDATE ${table} AS target + SET 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} + ) + 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` + 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/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/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/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/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/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"]); + }); }); 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..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 { @@ -32,6 +37,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 +171,69 @@ 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("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); 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, { diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index a8568f0fca..7346c699bf 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -2,10 +2,13 @@ import { describe, it, expect } from "vitest"; import { contentCreateBody, + contentListQuery, contentUpdateBody, createFieldBody, updateFieldBody, httpUrl, + localeCode, + localeFilterQuery, mediaUploadUrlBody, DEFAULT_MAX_UPLOAD_SIZE, } from "../../../src/api/schemas/index.js"; @@ -121,6 +124,46 @@ describe("contentUpdateBody schema", () => { }); }); +describe("localeCode validator", () => { + // 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"); + expect(localeCode.parse("zh-Hant")).toBe("zh-Hant"); + }); + + it("leaves plain language codes unchanged", () => { + expect(localeCode.parse("en")).toBe("en"); + }); + + it("still accepts mixed-case input (case-insensitive validation)", () => { + expect(localeCode.parse("EN-us")).toBe("EN-us"); + }); + + it("rejects malformed locale codes", () => { + expect(() => localeCode.parse("english")).toThrow(); + expect(() => localeCode.parse("e")).toThrow(); + expect(() => localeCode.parse("en_US")).toThrow(); + }); + + it("contentListQuery keeps the ?locale= filter casing", () => { + const result = contentListQuery.parse({ locale: "zh-TW" }); + expect(result.locale).toBe("zh-TW"); + }); + + it("contentCreateBody keeps the locale casing", () => { + const result = contentCreateBody.parse({ data: { title: "Hi" }, locale: "pt-BR" }); + expect(result.locale).toBe("pt-BR"); + }); + + it("localeFilterQuery keeps the ?locale= casing and rejects malformed values", () => { + expect(localeFilterQuery.parse({ locale: "zh-TW" }).locale).toBe("zh-TW"); + expect(localeFilterQuery.parse({}).locale).toBeUndefined(); + expect(() => localeFilterQuery.parse({ locale: "_invalid_" })).toThrow(); + }); +}); + describe("httpUrl validator", () => { it("accepts http URLs", () => { expect(httpUrl.parse("http://example.com")).toBe("http://example.com"); 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); + }); +});