diff --git a/.changeset/bylines-i18n.md b/.changeset/bylines-i18n.md new file mode 100644 index 000000000..88c18c5bd --- /dev/null +++ b/.changeset/bylines-i18n.md @@ -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. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index df9cc0cf5..b14a4b39c 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -109,6 +109,13 @@ export interface ContentEditorProps { item?: ContentItem | null; fields: Record; 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; @@ -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 */ @@ -196,6 +205,7 @@ export function ContentEditor({ item, fields, isNew, + entryLocale, isSaving, onSave, onAutosave, @@ -214,6 +224,7 @@ export function ContentEditor({ users, onAuthorChange, availableBylines, + availableBylinesLoaded, selectedBylines, onBylinesChange, onQuickCreateByline, @@ -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). @@ -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) @@ -338,6 +355,7 @@ export function ContentEditor({ }), ); pendingAutosaveStateRef.current = null; + setBylinesTouched(false); } }, [item?.updatedAt, itemDataString, item?.slug, item?.status]); @@ -345,6 +363,7 @@ export function ContentEditor({ const handleBylinesChange = React.useCallback( (next: BylineCreditInput[]) => { + setBylinesTouched(true); if (isNew) { onBylinesChange?.(next); return; @@ -416,15 +435,19 @@ export function ContentEditor({ // Schedule autosave autosaveTimeoutRef.current = setTimeout(() => { if (hasInvalidUrls(formDataRef.current)) return; - const payload = { + const payload: { + data: Record; + 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); @@ -443,6 +466,7 @@ export function ContentEditor({ isSaving, isAutosaving, activeBylines, + bylinesTouched, hasInvalidUrls, ]); @@ -455,11 +479,16 @@ export function ContentEditor({ clearTimeout(autosaveTimeoutRef.current); autosaveTimeoutRef.current = null; } - onSave?.({ + const payload: { + data: Record; + slug?: string; + bylines?: BylineCreditInput[]; + } = { data: formData, slug: slug || undefined, - bylines: activeBylines, - }); + }; + if (isNew || bylinesTouched) payload.bylines = activeBylines; + onSave?.(payload); }; // Preview URL state @@ -968,9 +997,14 @@ export function ContentEditor({ )} @@ -1894,6 +1928,17 @@ interface BylineCreditsEditorProps { bylineId: string, input: { slug: string; displayName: string }, ) => Promise; + /** + * 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({ @@ -1902,6 +1947,9 @@ function BylineCreditsEditor({ onChange, onQuickCreate, onQuickEdit, + entryLocale, + i18n, + bylinesLoaded = true, }: BylineCreditsEditorProps) { const { t } = useLingui(); const [selectedBylineId, setSelectedBylineId] = React.useState(""); @@ -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 (
+ {showLocaleEmptyState && ( +
+

+ {t`No bylines available in ${entryLocale}. Create a variant from the Bylines page before crediting one on this entry.`} +

+ + {t`Manage bylines in ${entryLocale}`} + +
+ )}
setSearch(e.target.value)} +
+ {isMultiLocale && i18n && activeLocale ? ( +
+
+

{t`Bylines`}

+
+ -
-
- setSearch(e.target.value)} + /> +
+
+ setForm((prev) => ({ ...prev, displayName: e.target.value }))} - /> - setForm((prev) => ({ ...prev, slug: e.target.value }))} - /> - setForm((prev) => ({ ...prev, websiteUrl: e.target.value }))} - /> - setForm((prev) => ({ ...prev, bio: e.target.value }))} - rows={5} - /> - setForm((prev) => ({ ...prev, displayName: e.target.value }))} + /> + setForm((prev) => ({ ...prev, slug: e.target.value }))} + /> + setForm((prev) => ({ ...prev, websiteUrl: e.target.value }))} + /> + setForm((prev) => ({ ...prev, bio: e.target.value }))} + rows={5} + /> +