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
69 changes: 69 additions & 0 deletions .changeset/bylines-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
"emdash": minor
"@emdash-cms/admin": minor
"@emdash-cms/auth": minor
---

Adds first-class i18n support for bylines, mirroring the row-per-locale model already used by menus and taxonomies (PR #916, migrations 036).

## Schema (migration 040)

`_emdash_bylines` gains two columns:

- `locale` — `TEXT NOT NULL DEFAULT 'en'`. Every row now belongs to exactly one locale.
- `translation_group` — `TEXT NOT NULL`. Shared across every locale variant of a single byline identity. The anchor row's `translation_group` equals its `id`; siblings inherit it.

A partial unique index `idx_bylines_group_locale_unique` enforces one row per `(translation_group, locale)`. The pre-existing `(slug)` unique index becomes `(slug, locale)` to allow the same slug across locales.

Existing rows are backfilled to the configured `defaultLocale` (or `'en'` if i18n isn't configured) with `translation_group = id`. Monolingual sites see no functional change; multilingual sites continue rendering the same byline data at the default locale until editors create translations.

## Credit hydration: strict per-locale

`_emdash_content_bylines.byline_id` now stores the byline's `translation_group`, not its row id. When an entry is rendered, credits are filtered by joining the junction against the byline sibling whose `locale` matches the entry's `locale`. If no sibling exists at the entry's locale, the credit hydrates as empty — there is **no fallback** to other locales' bios.

Author-inferred bylines (where an entry has no explicit credits but its author is linked to a byline) still fall back per-locale and respect the strictness gate: an entry with explicit credits at any locale will not infer from the author even if the explicit credits don't resolve at the rendering locale.

This is a deliberate behavior change for multilingual sites. The motivation is correctness: chain-walking credits across locales renders the wrong-language bio on translated entries.

The "explicit credit suppresses author fallback" check reads `primary_byline_id` directly from the content row — set by `setContentBylines` iff junction rows exist, backfilled by migration 040 for pre-existing rows. No separate probe against `_emdash_content_bylines` is needed at hydration time; the column is folded into the single per-entry context fetch (`author_id` + `primary_byline_id` in one query). Both monolingual and multilingual sites get the same query count.

## Identity lookups: chain-walk

`getBylineBySlug(slug, { locale })` walks the configured fallback chain (`resolveLocaleChain`), like `getMenu` and `getTerm`. Author pages for un-translated bylines still render an identity rather than 404'ing. This is conceptually distinct from credit hydration and runs through `requestCached` for per-render dedupe.

## Admin

- **TranslationsPanel** in the bylines editor lists every configured locale with Edit / Translate buttons. The Translate action POSTs to the new `POST /_emdash/api/admin/bylines/:id/translations` endpoint.
- **LocaleSwitcher** on `/bylines` filters the list strictly to one locale. Cross-locale navigation via TranslationsPanel routes through `/bylines?locale=…`.
- The **byline picker** on the content editor is locale-pinned to the entry's locale. Editors only see bylines that will actually hydrate at the entry's locale.
- The **byline credit empty state** on a locale with no bylines yet shows a CTA linking to `/bylines?locale=…` for inline creation.
- Translating an entry (`POST /content/:collection` with `translationOf`) calls `copyContentBylines` to inherit the source's credits — these resolve at the new entry's locale via the strict-hydration model, so credits "follow" the content across translations once sibling bylines exist.

## API additions

- `GET /_emdash/api/admin/bylines/:id/translations` — list every sibling row sharing a translation_group.
- `POST /_emdash/api/admin/bylines/:id/translations` — create a sibling at a target locale. Body defaults (slug, displayName, websiteUrl, avatar) inherit from the source.
- `POST /_emdash/api/admin/bylines` accepts `translationOf` + `locale` to create a sibling in one call.
- `GET /_emdash/api/admin/bylines?locale=…` filters strictly.
- `BylineSummary` gains `locale: string` and `translationGroup: string | null` (additive — existing consumers ignore the new fields).

## Permissions

Two new entries on `@emdash-cms/auth`:

- `bylines:read` — minimum `SUBSCRIBER`.
- `bylines:manage` — minimum `EDITOR`.

All byline routes (list, get, update, delete, translations) now check these instead of `content:read` / `Role.EDITOR`. Role thresholds are unchanged, so existing users see no permission differences. Custom RBAC configurations that bind to the old strings should add the new permission names.

## Repository

- `BylineRepository` is strict per-locale: `findMany`, `findBySlug`, `findById` accept an optional `locale` and return rows matching that locale (or all locales when omitted, for the manager view).
- New methods: `listTranslations(id)`, `findByTranslationGroup(group)`, `copyContentBylines(collection, fromId, toId)`.
- `setContentBylines` deduplicates by `translation_group` after resolving wire row ids, so passing two sibling row ids of the same identity collapses to one credit row.
- `delete` is sibling-aware: removing one locale variant leaves siblings standing.

## Notable trade-offs

- **Strict hydration over chain-walking** for credits. Chain-walking would render mismatched-language bios on translated content. The honest answer is to show nothing rather than the wrong thing; the picker tells editors which bylines will resolve at the entry's locale, and the empty-state CTA makes creating a sibling a one-click flow.
- **Schema is row-per-locale**, not a separate `byline_translations` side-table. Matches the existing content / menu / taxonomy convention so query patterns and indexes are consistent across the codebase.
82 changes: 76 additions & 6 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ export interface ContentEditorProps {
item?: ContentItem | null;
fields: Record<string, FieldDescriptor>;
isNew?: boolean;
/**
* Locale this entry is bound to. For existing entries this matches
* `item.locale`; for new entries it's the URL `?locale=` (or default).
* Threaded into the byline picker so the empty-state CTA links to the
* right locale on the Bylines manager.
*/
entryLocale?: string | null;
isSaving?: boolean;
onSave?: (payload: {
data: Record<string, unknown>;
Expand Down Expand Up @@ -149,6 +156,8 @@ export interface ContentEditorProps {
onAuthorChange?: (authorId: string | null) => void;
/** Available byline profiles */
availableBylines?: BylineSummary[];
/** Whether the parent's byline picker query has resolved. Suppresses the empty-state flash before first fetch. */
availableBylinesLoaded?: boolean;
/** Selected byline credits (controlled for new entries) */
selectedBylines?: BylineCreditInput[];
/** Callback when byline credits are changed */
Expand Down Expand Up @@ -196,6 +205,7 @@ export function ContentEditor({
item,
fields,
isNew,
entryLocale,
isSaving,
onSave,
onAutosave,
Expand All @@ -214,6 +224,7 @@ export function ContentEditor({
users,
onAuthorChange,
availableBylines,
availableBylinesLoaded,
selectedBylines,
onBylinesChange,
onQuickCreateByline,
Expand All @@ -238,6 +249,11 @@ export function ContentEditor({
item?.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ??
[],
);
// Gates whether `bylines` is included in the save payload. Untouched
// edits must not ship `[]` — strict per-locale hydration can return
// empty for entries with credits at other locales, and sending `[]`
// would wipe them.
const [bylinesTouched, setBylinesTouched] = React.useState(false);

// Track portableText editor for document outline. Only the "content"
// field wires its editor into this slot (see onEditorReady below).
Expand Down Expand Up @@ -311,6 +327,7 @@ export function ContentEditor({
}),
);
pendingAutosaveStateRef.current = null;
setBylinesTouched(false);
}

// Update form and last saved state when item changes (e.g., after save or restore)
Expand Down Expand Up @@ -338,13 +355,15 @@ export function ContentEditor({
}),
);
pendingAutosaveStateRef.current = null;
setBylinesTouched(false);
}
}, [item?.updatedAt, itemDataString, item?.slug, item?.status]);

const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines;

const handleBylinesChange = React.useCallback(
(next: BylineCreditInput[]) => {
setBylinesTouched(true);
if (isNew) {
onBylinesChange?.(next);
return;
Expand Down Expand Up @@ -416,15 +435,19 @@ export function ContentEditor({
// Schedule autosave
autosaveTimeoutRef.current = setTimeout(() => {
if (hasInvalidUrls(formDataRef.current)) return;
const payload = {
const payload: {
data: Record<string, unknown>;
slug?: string;
bylines?: BylineCreditInput[];
} = {
data: formDataRef.current,
slug: slugRef.current || undefined,
bylines: activeBylines,
};
if (bylinesTouched) payload.bylines = activeBylines;
pendingAutosaveStateRef.current = serializeEditorState({
data: payload.data,
slug: payload.slug || "",
bylines: payload.bylines,
bylines: activeBylines,
});
onAutosave(payload);
}, AUTOSAVE_DELAY);
Expand All @@ -443,6 +466,7 @@ export function ContentEditor({
isSaving,
isAutosaving,
activeBylines,
bylinesTouched,
hasInvalidUrls,
]);

Expand All @@ -455,11 +479,16 @@ export function ContentEditor({
clearTimeout(autosaveTimeoutRef.current);
autosaveTimeoutRef.current = null;
}
onSave?.({
const payload: {
data: Record<string, unknown>;
slug?: string;
bylines?: BylineCreditInput[];
} = {
data: formData,
slug: slug || undefined,
bylines: activeBylines,
});
};
if (isNew || bylinesTouched) payload.bylines = activeBylines;
onSave?.(payload);
};

// Preview URL state
Expand Down Expand Up @@ -968,9 +997,14 @@ export function ContentEditor({
<BylineCreditsEditor
credits={activeBylines}
bylines={availableBylines ?? []}
bylinesLoaded={availableBylinesLoaded}
onChange={handleBylinesChange}
onQuickCreate={onQuickCreateByline}
onQuickEdit={onQuickEditByline}
// Existing entry: use its own locale. New entry: use the
// URL `?locale=` (passed in via `entryLocale`).
entryLocale={item?.locale ?? entryLocale}
i18n={i18n}
/>
</div>
)}
Expand Down Expand Up @@ -1894,6 +1928,17 @@ interface BylineCreditsEditorProps {
bylineId: string,
input: { slug: string; displayName: string },
) => Promise<BylineSummary>;
/**
* Locale of the entry being edited. When the picker comes back empty and
* the install is multi-locale, the empty-state copy and CTA link are
* scoped to this locale (post-migration 040, the picker is strict
* per-locale — see the bylines manager flow).
*/
entryLocale?: string | null;
/** i18n config from the manifest. When set with >1 locales, the editor renders the locale-scoped empty-state. */
i18n?: { defaultLocale: string; locales: string[] } | null;
/** Suppresses the empty-state until the picker query resolves. Defaults to true. */
bylinesLoaded?: boolean;
}

function BylineCreditsEditor({
Expand All @@ -1902,6 +1947,9 @@ function BylineCreditsEditor({
onChange,
onQuickCreate,
onQuickEdit,
entryLocale,
i18n,
bylinesLoaded = true,
}: BylineCreditsEditorProps) {
const { t } = useLingui();
const [selectedBylineId, setSelectedBylineId] = React.useState("");
Expand Down Expand Up @@ -1949,8 +1997,30 @@ function BylineCreditsEditor({
setEditError(null);
};

// Multi-locale install with no bylines at the entry's locale: show a
// CTA to the byline manager, scoped to that locale. Quick-create
// still works inline.
const isMultiLocale = !!i18n && i18n.locales.length > 1;
const showLocaleEmptyState =
isMultiLocale && bylinesLoaded && bylines.length === 0 && !!entryLocale;

return (
<div className="space-y-3">
{showLocaleEmptyState && (
<div className="rounded border border-dashed p-3 text-sm space-y-2">
<p className="text-kumo-subtle">
{t`No bylines available in ${entryLocale}. Create a variant from the Bylines page before crediting one on this entry.`}
</p>
<RouterLinkButton
to="/bylines"
search={{ locale: entryLocale ?? undefined }}
variant="secondary"
size="sm"
>
{t`Manage bylines in ${entryLocale}`}
</RouterLinkButton>
</div>
)}
<div className="flex gap-2">
<Select
value={selectedBylineId}
Expand Down
58 changes: 58 additions & 0 deletions packages/admin/src/lib/api/bylines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export interface BylineSummary {
isGuest: boolean;
createdAt: string;
updatedAt: string;
/** Locale this byline row is presented in (migration 040). */
locale: string;
/**
* Shared across translations of the same byline (migration 040).
* Nullable for backwards compatibility; new rows always populate it.
*/
translationGroup: string | null;
}

export interface BylineInput {
Expand All @@ -30,6 +37,25 @@ export interface BylineInput {
websiteUrl?: string | null;
userId?: string | null;
isGuest?: boolean;
/**
* Locale this byline row belongs to. When omitted, the server uses the
* configured `defaultLocale`.
*/
locale?: string;
/**
* When set, the new row joins the source byline's `translation_group`.
* Requires `locale` (the server returns a validation error otherwise).
*/
translationOf?: string;
}

export interface BylineTranslationInput {
locale: string;
slug?: string;
displayName?: string;
bio?: string | null;
avatarMediaId?: string | null;
websiteUrl?: string | null;
}

export interface BylineCreditInput {
Expand All @@ -41,13 +67,15 @@ export async function fetchBylines(options?: {
search?: string;
isGuest?: boolean;
userId?: string;
locale?: string;
cursor?: string;
limit?: number;
}): Promise<FindManyResult<BylineSummary>> {
const params = new URLSearchParams();
if (options?.search) params.set("search", options.search);
if (options?.isGuest !== undefined) params.set("isGuest", String(options.isGuest));
if (options?.userId) params.set("userId", options.userId);
if (options?.locale) params.set("locale", options.locale);
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));

Expand Down Expand Up @@ -88,3 +116,33 @@ export async function deleteByline(id: string): Promise<void> {
});
if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete byline`));
}

/**
* Fetch every translation of a byline (siblings sharing the same
* translation_group).
*/
export async function fetchBylineTranslations(id: string): Promise<{ items: BylineSummary[] }> {
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}/translations`);
return parseApiResponse<{ items: BylineSummary[] }>(
response,
"Failed to fetch byline translations",
);
}

/**
* Create a new locale variant of a byline. The new row joins the source's
* `translation_group`. Body defaults — slug, display name, avatar, website —
* inherit from the source when omitted, so editors only have to fill in the
* localized bio (and optionally a localized display name).
*/
export async function createBylineTranslation(
id: string,
input: BylineTranslationInput,
): Promise<BylineSummary> {
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}/translations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<BylineSummary>(response, "Failed to create byline translation");
}
3 changes: 3 additions & 0 deletions packages/admin/src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,15 @@ export {
export {
type BylineSummary,
type BylineInput,
type BylineTranslationInput,
type BylineCreditInput,
fetchBylines,
fetchByline,
createByline,
updateByline,
deleteByline,
fetchBylineTranslations,
createBylineTranslation,
} from "./bylines.js";

// Menus
Expand Down
Loading
Loading