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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/localecode-preserve-casing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes 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.
9 changes: 5 additions & 4 deletions packages/core/src/api/handlers/bylines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 25 additions & 7 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -269,7 +269,11 @@ async function resolveId(
identifier: string,
locale?: string,
): Promise<string | null> {
const item = await repo.findByIdOrSlug(collection, identifier, locale);
const item = await repo.findByIdOrSlug(
collection,
identifier,
locale ? resolveConfiguredLocale(locale) : undefined,
);
return item?.id ?? null;
}

Expand All @@ -283,7 +287,11 @@ async function resolveIdIncludingTrashed(
identifier: string,
locale?: string,
): Promise<string | null> {
const item = await repo.findByIdOrSlugIncludingTrashed(collection, identifier, locale);
const item = await repo.findByIdOrSlugIncludingTrashed(
collection,
identifier,
locale ? resolveConfiguredLocale(locale) : undefined,
);
return item?.id ?? null;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -579,7 +587,11 @@ export async function handleContentGet(
): Promise<ApiResult<ContentResponse>> {
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 {
Expand Down Expand Up @@ -624,7 +636,11 @@ export async function handleContentGetIncludingTrashed(
): Promise<ApiResult<ContentResponse>> {
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 {
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 11 additions & 6 deletions packages/core/src/api/handlers/menus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,7 +89,8 @@ async function resolveMenu(
name: string,
options: { locale?: string },
): Promise<ResolveMenuResult> {
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,
Expand Down Expand Up @@ -124,7 +125,8 @@ export async function handleMenuList(
): Promise<ApiResult<MenuListItem[]>> {
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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -212,7 +216,8 @@ export async function handleMenuGet(
): Promise<ApiResult<MenuWithItems>> {
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,
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/api/handlers/relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -127,7 +131,8 @@ export async function handleRelationList(
): Promise<ApiResult<{ relations: Relation[] }>> {
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 {
Expand Down
36 changes: 22 additions & 14 deletions packages/core/src/api/handlers/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -174,8 +175,9 @@ export async function handleTaxonomyList(
options: { locale?: string } = {},
): Promise<ApiResult<TaxonomyListResponse>> {
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(),
Expand Down Expand Up @@ -216,6 +218,7 @@ export async function handleTaxonomyCreate(
},
): Promise<ApiResult<{ taxonomy: TaxonomyDef }>> {
try {
const locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined;
if (!NAME_PATTERN.test(input.name)) {
return {
success: false,
Expand Down Expand Up @@ -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}'`,
},
};
}
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -535,6 +539,7 @@ export async function handleTermCreate(
},
): Promise<ApiResult<TermResponse>> {
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*.
Expand All @@ -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}'`,
},
};
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -635,7 +640,8 @@ export async function handleTermGet(
): Promise<ApiResult<TermGetResponse>> {
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 {
Expand Down Expand Up @@ -743,7 +749,8 @@ export async function handleTermUpdate(
): Promise<ApiResult<TermResponse>> {
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 {
Expand All @@ -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,
Expand Down Expand Up @@ -832,7 +839,8 @@ export async function handleTermDelete(
): Promise<ApiResult<{ deleted: true }>> {
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 {
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/api/schemas/bylines.ts
Original file line number Diff line number Diff line change
@@ -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-]*$/;
Expand Down Expand Up @@ -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" });

Expand All @@ -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`.
Expand All @@ -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)
Expand Down
Loading
Loading