diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md
index b42b76e4c..0cce268c6 100644
--- a/docs/how-to-guides/index.md
+++ b/docs/how-to-guides/index.md
@@ -30,4 +30,5 @@ icons
configure-plate-code-block-languages
bind-metadata-fields-to-plate-text-blocks
custom-content-types
+translate-content
```
diff --git a/docs/how-to-guides/translate-content.md b/docs/how-to-guides/translate-content.md
new file mode 100644
index 000000000..f621195ad
--- /dev/null
+++ b/docs/how-to-guides/translate-content.md
@@ -0,0 +1,65 @@
+---
+myst:
+ html_meta:
+ "description": "How to create a translation of a content item in Plone Aurora while reading the original side by side"
+ "property=og:description": "How to create a translation of a content item in Plone Aurora while reading the original side by side"
+ "property=og:title": "Translate content"
+ "keywords": "Plone Aurora, translate, translation, multilingual, babel"
+---
+
+# Translate content
+
+This guide shows you how to create a translation of a content item while reading the original side by side.
+
+## Requirements
+
+Your site must be multilingual.
+Install the `plone.app.multilingual` add-on, and configure at least two site languages in the {guilabel}`Language` control panel.
+The backend then provides one root folder per language, and Aurora places every translation in the folder of its language.
+
+## Open the translation view
+
+Open the translation view directly by URL.
+Append the path of the item to translate to `@@translate`, and pass the target language as a query parameter.
+
+```text
+https://example.com/@@translate/?language=
+```
+
+For example, to translate the English page at `/en/welcome` to German, open the following URL.
+
+```text
+https://example.com/@@translate/en/welcome?language=de
+```
+
+If a translation in the target language already exists, the view redirects to the edit view of that translation.
+
+```{todo}
+Describe the user interface entry point for the translation view, once the toolbar provides a {guilabel}`Translate` action.
+See [issue 21](https://github.com/plone/aurora/issues/21) and [issue 31](https://github.com/plone/aurora/issues/31).
+```
+
+## Translate the content
+
+The view shows the original on the left, and the new translation on the right.
+The {guilabel}`Blocks` and {guilabel}`Content` tabs switch both columns at the same time.
+
+In the {guilabel}`Blocks` tab, the left column shows the rendered original, and the right column shows the block editor for the translation.
+The translation starts with the same block structure as the original.
+
+- Text blocks contain the original text.
+ Overwrite it with your translation.
+- All other blocks, such as a teaser, appear empty at their original position.
+ Fill them as you would fill a manually added block.
+
+In the {guilabel}`Content` tab, the left column shows the field values of the original, and the right column shows the fields of the translation.
+The fields start empty and show the values of the original as placeholders.
+Enter the translated values, starting with the {guilabel}`Title`.
+
+## Save the translation
+
+Select the {guilabel}`Save` button in the toolbar.
+Aurora creates the translation in the root folder of the target language, links it to the original, and opens the edit view of the new translation.
+
+Fields that you leave empty stay empty on the translation.
+The link between the original and the translation lets visitors switch between the two language versions of the item.
diff --git a/packages/cmsui/components/ContentForm/ContentForm.tsx b/packages/cmsui/components/ContentForm/ContentForm.tsx
index 7de5f8a3c..cbf307bd7 100644
--- a/packages/cmsui/components/ContentForm/ContentForm.tsx
+++ b/packages/cmsui/components/ContentForm/ContentForm.tsx
@@ -40,6 +40,10 @@ interface ContentFormProps {
schema: Schema;
heading: ReactNode;
submitMethod: 'post' | 'patch';
+ /** Per-tab sticky panels rendered left of the form (eg. translation source). */
+ asidePanels?: { header?: ReactNode; blocks?: ReactNode; content?: ReactNode };
+ /** Placeholder text per field name (eg. the source values of a translation). */
+ fieldPlaceholders?: Record;
}
export default function ContentForm({
@@ -47,6 +51,8 @@ export default function ContentForm({
schema,
heading,
submitMethod,
+ asidePanels,
+ fieldPlaceholders,
}: ContentFormProps) {
const { t } = useTranslation();
const fetcher = useFetcher();
@@ -64,6 +70,68 @@ export default function ContentForm({
},
});
+ const fieldsetsForm = (
+
+ );
+
+ // The shared header row keeps both columns starting level; px-6 mirrors the
+ // blocks editor's own gutters, padBody pads bodies that lack them.
+ const withAside = (panel: ReactNode, body: ReactNode, padBody = false) =>
+ asidePanels ? (
+
+
{asidePanels.header}
+
{heading}
+
+
+ {body}
+
+
+ ) : (
+ body
+ );
+
return (
@@ -86,59 +154,22 @@ export default function ContentForm({
{
id: 'blocks',
title: t('cmsui.blocksEditor.blocksTab'),
- content: ,
+ content: withAside(asidePanels?.blocks, ),
},
{
id: 'content',
title: t('cmsui.blocksEditor.contentTab'),
- content: (
-
+ content: withAside(
+ asidePanels?.content,
+ asidePanels ? (
+ fieldsetsForm
+ ) : (
+
+
{heading}
+ {fieldsetsForm}
+
+ ),
+ true,
),
},
]}
diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts
index cccac5bec..cb708daf2 100644
--- a/packages/cmsui/config/routes.ts
+++ b/packages/cmsui/config/routes.ts
@@ -49,6 +49,17 @@ export default function install(config: ConfigType) {
},
],
},
+ {
+ type: 'prefix',
+ path: '@@translate',
+ children: [
+ {
+ type: 'route',
+ path: '*',
+ file: '@plone/cmsui/routes/translate.tsx',
+ },
+ ],
+ },
{
type: 'prefix',
path: 'controlpanel',
diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json
index fcd14d912..23f85d184 100644
--- a/packages/cmsui/locales/de/common.json
+++ b/packages/cmsui/locales/de/common.json
@@ -56,6 +56,11 @@
"groupMembership": "Gruppenmitgliedschaft",
"groups": "Gruppen"
},
+ "translate": {
+ "source": "Quelle",
+ "view_source": "Quellinhalt ansehen",
+ "heading_new": "\"{{title}}\" nach {{language}} übersetzen"
+ },
"blocksEditor": {
"blocksTab": "Blöcke",
"contentTab": "Inhalt"
diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json
index 46fd15f42..6d5606ffb 100644
--- a/packages/cmsui/locales/en/common.json
+++ b/packages/cmsui/locales/en/common.json
@@ -104,6 +104,11 @@
"groupMembership": "Group Membership",
"groups": "Groups"
},
+ "translate": {
+ "source": "Source",
+ "view_source": "View source content",
+ "heading_new": "Translate \"{{title}}\" to {{language}}"
+ },
"blocksEditor": {
"blocksTab": "Blocks",
"contentTab": "Content"
diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json
index 7cd7fb030..eeb7db260 100644
--- a/packages/cmsui/locales/it/common.json
+++ b/packages/cmsui/locales/it/common.json
@@ -94,6 +94,11 @@
"groupMembership": "Appartenenza ai gruppi",
"groups": "Gruppi"
},
+ "translate": {
+ "source": "Origine",
+ "view_source": "Visualizza il contenuto di origine",
+ "heading_new": "Traduci \"{{title}}\" in {{language}}"
+ },
"blocksEditor": {
"blocksTab": "Blocchi",
"contentTab": "Contenuto"
diff --git a/packages/cmsui/news/+translate-route.feature b/packages/cmsui/news/+translate-route.feature
new file mode 100644
index 000000000..209c4891a
--- /dev/null
+++ b/packages/cmsui/news/+translate-route.feature
@@ -0,0 +1 @@
+Add `@@translate` route to create and edit translations of a content item. The form is pre-filled with the source content (or the existing translation), and saving creates the translation in the target language root folder and links it to the source. @nils-pzr
diff --git a/packages/cmsui/routes/translate.test.tsx b/packages/cmsui/routes/translate.test.tsx
new file mode 100644
index 000000000..0115c80fb
--- /dev/null
+++ b/packages/cmsui/routes/translate.test.tsx
@@ -0,0 +1,290 @@
+import { expect, describe, it, vi, afterEach } from 'vitest';
+import config from '@plone/registry';
+import { loader, action } from './translate';
+import { RouterContextProvider } from 'react-router';
+import {
+ ploneClientContext,
+ ploneContentContext,
+} from '@plone/aurora/app/middleware.server';
+
+vi.mock('@plone/react-router', () => ({
+ requireAuthCookie: vi.fn().mockResolvedValue('fake-token'),
+}));
+
+const mockSchema = {
+ title: 'Page',
+ fieldsets: [
+ { id: 'default', title: 'Default', fields: ['title', 'description'] },
+ ],
+ properties: {
+ title: { title: 'Title', type: 'string' },
+ description: { title: 'Description', type: 'string' },
+ },
+ required: ['title'],
+};
+
+const mockSource = {
+ '@id': 'http://example.com/en/my-page',
+ '@type': 'Document',
+ title: 'My Page',
+ description: 'A test page',
+ language: { title: 'English', token: 'en' },
+ blocks: {
+ __somersault__: {
+ '@type': '__somersault__',
+ value: [
+ { type: 'title', children: [{ text: 'My Page' }] },
+ { type: 'p', children: [{ text: 'Hello world' }] },
+ {
+ type: 'unknown',
+ '@type': 'teaser',
+ id: 'abc123',
+ href: [{ '@id': 'http://example.com/en/other' }],
+ children: [{ text: '' }],
+ },
+ ],
+ },
+ },
+ blocks_layout: { items: [] },
+};
+
+const mockTranslations = {
+ '@id': 'http://example.com/en/my-page/@translations',
+ items: [],
+ root: { en: 'http://example.com/en', de: 'http://example.com/de' },
+};
+
+function makeContext(client: Record) {
+ const context = new RouterContextProvider();
+ context.set(ploneClientContext, client as any);
+ context.set(ploneContentContext, mockSource as any);
+ return context;
+}
+
+function loaderArgs(context: RouterContextProvider, url: string) {
+ const request = new Request(url);
+ return {
+ request,
+ params: { '*': 'en/my-page' },
+ context,
+ unstable_pattern: '/@@translate/*',
+ unstable_url: new URL(request.url),
+ };
+}
+
+async function caughtStatus(promise: Promise) {
+ try {
+ await promise;
+ return null;
+ } catch (error: any) {
+ return error?.init?.status ?? error?.status ?? 'unknown';
+ }
+}
+
+describe('Translate route', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ config.settings = {};
+ });
+
+ describe('loader', () => {
+ it('returns the source and an initial with kept text and emptied data blocks', async () => {
+ config.settings.apiPath = 'http://example.com';
+ const context = makeContext({
+ getType: vi.fn().mockResolvedValue({ data: mockSchema }),
+ getTranslation: vi.fn().mockResolvedValue({ data: mockTranslations }),
+ });
+
+ const result = await loader(
+ loaderArgs(
+ context,
+ 'http://example.com/@@translate/en/my-page?language=de',
+ ) as any,
+ );
+
+ const { source, initial, targetLanguage } = (result as any).data;
+ expect(targetLanguage).toBe('de');
+ expect(source.title).toBe('My Page');
+ expect(initial.title).toBe('');
+ expect(initial['@type']).toBe('Document');
+
+ const value = initial.blocks.__somersault__.value;
+ expect(value[0].children[0].text).toBe('My Page');
+ expect(value[1].children[0].text).toBe('Hello world');
+ expect(value[2]).toEqual({
+ type: 'unknown',
+ '@type': 'teaser',
+ id: 'abc123',
+ children: [{ text: '' }],
+ });
+ });
+
+ it('redirects to the edit route when the translation already exists', async () => {
+ config.settings.apiPath = 'http://example.com';
+ const context = makeContext({
+ getType: vi.fn().mockResolvedValue({ data: mockSchema }),
+ getTranslation: vi.fn().mockResolvedValue({
+ data: {
+ ...mockTranslations,
+ items: [
+ { '@id': 'http://example.com/de/meine-seite', language: 'de' },
+ ],
+ },
+ }),
+ });
+
+ try {
+ await loader(
+ loaderArgs(
+ context,
+ 'http://example.com/@@translate/en/my-page?language=de',
+ ) as any,
+ );
+ expect.unreachable('loader should redirect');
+ } catch (response: any) {
+ expect(response.status).toBe(302);
+ expect(response.headers.get('Location')).toBe('/@@edit/de/meine-seite');
+ }
+ });
+
+ it('rejects a missing target language', async () => {
+ config.settings.apiPath = 'http://example.com';
+ const context = makeContext({
+ getType: vi.fn().mockResolvedValue({ data: mockSchema }),
+ getTranslation: vi.fn().mockResolvedValue({ data: mockTranslations }),
+ });
+
+ const status = await caughtStatus(
+ loader(
+ loaderArgs(
+ context,
+ 'http://example.com/@@translate/en/my-page',
+ ) as any,
+ ) as Promise,
+ );
+ expect(status).toBe(400);
+ });
+
+ it('rejects a target language without a language root', async () => {
+ config.settings.apiPath = 'http://example.com';
+ const context = makeContext({
+ getType: vi.fn().mockResolvedValue({ data: mockSchema }),
+ getTranslation: vi.fn().mockResolvedValue({ data: mockTranslations }),
+ });
+
+ const status = await caughtStatus(
+ loader(
+ loaderArgs(
+ context,
+ 'http://example.com/@@translate/en/my-page?language=fr',
+ ) as any,
+ ) as Promise,
+ );
+ expect(status).toBe(400);
+ });
+
+ it('rejects untranslatable content', async () => {
+ config.settings.apiPath = 'http://example.com';
+ const context = makeContext({
+ getType: vi.fn().mockResolvedValue({ data: mockSchema }),
+ getTranslation: vi.fn().mockRejectedValue({ status: 404 }),
+ });
+
+ const status = await caughtStatus(
+ loader(
+ loaderArgs(
+ context,
+ 'http://example.com/@@translate/en/my-page?language=de',
+ ) as any,
+ ) as Promise,
+ );
+ expect(status).toBe(400);
+ });
+ });
+
+ describe('action', () => {
+ function actionArgs(context: RouterContextProvider, body: unknown) {
+ const request = new Request(
+ 'http://example.com/@@translate/en/my-page?language=de',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ },
+ );
+ return {
+ request,
+ params: { '*': 'en/my-page' },
+ context,
+ unstable_pattern: '/@@translate/*',
+ unstable_url: new URL(request.url),
+ };
+ }
+
+ it('creates the translation in the language root, links it, and redirects', async () => {
+ config.settings.apiPath = 'http://example.com';
+ const createContent = vi
+ .fn()
+ .mockResolvedValue({ data: { '@id': 'http://example.com/de/titel' } });
+ const linkTranslation = vi.fn().mockResolvedValue({});
+ const context = makeContext({
+ getTranslation: vi.fn().mockResolvedValue({ data: mockTranslations }),
+ createContent,
+ linkTranslation,
+ });
+
+ const result = await action(
+ actionArgs(context, {
+ title: 'Titel',
+ '@id': '/should-be-stripped',
+ }) as any,
+ );
+
+ expect(createContent).toHaveBeenCalledWith({
+ path: '/de',
+ data: expect.objectContaining({
+ title: 'Titel',
+ '@type': 'Document',
+ language: 'de',
+ }),
+ });
+ expect(createContent.mock.calls[0][0].data['@id']).toBeUndefined();
+ expect(linkTranslation).toHaveBeenCalledWith({
+ path: '/en/my-page',
+ data: { id: '/de/titel' },
+ });
+ expect((result as Response).status).toBe(302);
+ expect((result as Response).headers.get('Location')).toBe(
+ '/@@edit/de/titel',
+ );
+ });
+
+ it('updates the translation instead when it already exists', async () => {
+ config.settings.apiPath = 'http://example.com';
+ const updateContent = vi.fn().mockResolvedValue({});
+ const context = makeContext({
+ getTranslation: vi.fn().mockResolvedValue({
+ data: {
+ ...mockTranslations,
+ items: [
+ { '@id': 'http://example.com/de/meine-seite', language: 'de' },
+ ],
+ },
+ }),
+ updateContent,
+ });
+
+ const result = await action(
+ actionArgs(context, { title: 'Titel' }) as any,
+ );
+
+ expect(updateContent).toHaveBeenCalledWith({
+ path: '/de/meine-seite',
+ data: expect.objectContaining({ title: 'Titel' }),
+ });
+ expect((result as Response).headers.get('Location')).toBe(
+ '/@@edit/de/meine-seite',
+ );
+ });
+ });
+});
diff --git a/packages/cmsui/routes/translate.tsx b/packages/cmsui/routes/translate.tsx
new file mode 100644
index 000000000..e11a4300c
--- /dev/null
+++ b/packages/cmsui/routes/translate.tsx
@@ -0,0 +1,337 @@
+import {
+ data,
+ redirect,
+ RouterContextProvider,
+ useLoaderData,
+ type ActionFunctionArgs,
+ type LoaderFunctionArgs,
+} from 'react-router';
+import { useTranslation } from 'react-i18next';
+import { flattenToAppURL, hasBlocksData } from '@plone/helpers';
+import { requireAuthCookie } from '@plone/react-router';
+import type { Content } from '@plone/types';
+import {
+ ploneClientContext,
+ ploneContentContext,
+} from '@plone/aurora/app/middleware.server';
+import RenderBlocks from '@plone/layout/blocks/RenderBlocks';
+import config from '@plone/registry';
+import ContentForm from '../components/ContentForm/ContentForm';
+
+const SERVER_MANAGED_KEYS = [
+ '@id',
+ '@components',
+ 'UID',
+ 'id',
+ 'review_state',
+ 'is_folderish',
+ 'parent',
+];
+
+function stripServerKeys(content: Record) {
+ const clean: Record = {};
+ for (const [key, value] of Object.entries(content)) {
+ if (!SERVER_MANAGED_KEYS.includes(key)) clean[key] = value;
+ }
+ return clean;
+}
+
+const TEXT_NODE_TYPES = [
+ 'title',
+ 'description',
+ 'p',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'blockquote',
+ 'ul',
+ 'ol',
+ 'li',
+ 'lic',
+ 'a',
+ 'code',
+ 'pre',
+];
+
+const TEXT_BLOCK_TYPES = ['slate', 'text', 'title', 'description'];
+
+type PlateNode = { type?: string; children?: unknown } & Record<
+ string,
+ unknown
+>;
+
+function placeholderValueNodes(nodes: unknown): unknown {
+ if (!Array.isArray(nodes)) return nodes;
+ return nodes.map((node) => {
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
+ const plateNode = node as PlateNode;
+ const type = plateNode.type;
+ if (type && !TEXT_NODE_TYPES.includes(type) && !('text' in plateNode)) {
+ // Without the adapted block's '@type', the block adapter renders nothing.
+ return {
+ type,
+ ...(typeof plateNode.id === 'string' ? { id: plateNode.id } : {}),
+ ...(typeof plateNode['@type'] === 'string'
+ ? { '@type': plateNode['@type'] }
+ : {}),
+ children: [{ text: '' }],
+ };
+ }
+ return {
+ ...plateNode,
+ ...(Array.isArray(plateNode.children)
+ ? { children: placeholderValueNodes(plateNode.children) }
+ : {}),
+ };
+ });
+}
+
+function hasVisibleNodes(nodes: unknown): boolean {
+ if (!Array.isArray(nodes)) return false;
+ return nodes.some((node) => {
+ if (!node || typeof node !== 'object') return false;
+ const plateNode = node as PlateNode;
+ if (typeof plateNode.text === 'string' && plateNode.text.trim())
+ return true;
+ if (typeof plateNode['@type'] === 'string') return true;
+ return hasVisibleNodes(plateNode.children);
+ });
+}
+
+// The middleware migrates all content to a somersault block, even empty one,
+// so block presence alone proves nothing.
+function hasVisibleSourceBlocks(source: Content): boolean {
+ const blocks = (source.blocks ?? {}) as Record<
+ string,
+ { value?: unknown } | undefined
+ >;
+ const somersault = blocks.__somersault__;
+ if (somersault) return hasVisibleNodes(somersault.value);
+ return hasBlocksData(source) && Object.keys(blocks).length > 0;
+}
+
+function placeholderBlocks(blocks: Record) {
+ const clean: Record = {};
+ for (const [id, block] of Object.entries(blocks)) {
+ const blockData = (block ?? {}) as Record;
+ const type = blockData['@type'] as string | undefined;
+ if (type === '__somersault__') {
+ clean[id] = {
+ ...blockData,
+ value: placeholderValueNodes(blockData.value),
+ };
+ } else if (type && TEXT_BLOCK_TYPES.includes(type)) {
+ clean[id] = blockData;
+ } else {
+ clean[id] = { '@type': type };
+ }
+ }
+ return clean;
+}
+
+export async function loader({
+ request,
+ context,
+}: LoaderFunctionArgs) {
+ await requireAuthCookie(request);
+
+ const cli = context.get(ploneClientContext);
+ const source = context.get(ploneContentContext);
+ const sourcePath = flattenToAppURL(source['@id']);
+
+ const targetLanguage =
+ new URL(request.url).searchParams.get('language') || '';
+
+ let schema;
+ let translations;
+ try {
+ [{ data: schema }, { data: translations }] = await Promise.all([
+ cli.getType({ type: source['@type'] }),
+ cli.getTranslation({ path: sourcePath }),
+ ]);
+ } catch {
+ // eg. content outside a language root
+ throw data('Content is not translatable', { status: 400 });
+ }
+
+ if (!targetLanguage || !(translations.root ?? {})[targetLanguage]) {
+ throw data('Unknown target language', { status: 400 });
+ }
+
+ const existing = (translations.items ?? []).find(
+ (item) => item.language === targetLanguage,
+ );
+ if (existing) {
+ throw redirect(`/@@edit${flattenToAppURL(existing['@id'])}`);
+ }
+
+ const initial = {
+ '@type': source['@type'],
+ title: '',
+ blocks: placeholderBlocks((source.blocks ?? {}) as Record),
+ blocks_layout: source.blocks_layout ?? { items: [] },
+ } as unknown as Content;
+
+ return data(
+ flattenToAppURL({
+ source,
+ schema,
+ initial,
+ targetLanguage,
+ }),
+ );
+}
+
+export async function action({
+ request,
+ params,
+ context,
+}: ActionFunctionArgs) {
+ await requireAuthCookie(request);
+
+ const cli = context.get(ploneClientContext);
+ const source = context.get(ploneContentContext);
+ const sourcePath = `/${params['*'] || ''}`;
+ const targetLanguage =
+ new URL(request.url).searchParams.get('language') || '';
+
+ const formData = (await request.json()) as Record;
+
+ const { data: translations } = await cli.getTranslation({
+ path: sourcePath,
+ });
+ const existing = (translations.items ?? []).find(
+ (item) => item.language === targetLanguage,
+ );
+
+ if (existing) {
+ const translationPath = flattenToAppURL(existing['@id']);
+ await cli.updateContent({
+ path: translationPath,
+ data: stripServerKeys(formData),
+ });
+ return redirect(`/@@edit${translationPath}`);
+ }
+
+ const targetRoot = flattenToAppURL(
+ (translations.root ?? {})[targetLanguage] ?? '',
+ );
+ if (!targetLanguage || !targetRoot) {
+ throw data('Unknown target language', { status: 400 });
+ }
+ const created = await cli.createContent({
+ path: targetRoot,
+ data: {
+ ...stripServerKeys(formData),
+ '@type': source['@type'],
+ language: targetLanguage,
+ } as any,
+ });
+ const translationPath = flattenToAppURL(created.data['@id']);
+ await cli.linkTranslation({
+ path: sourcePath,
+ data: { id: translationPath },
+ });
+
+ return redirect(`/@@edit${translationPath}`);
+}
+
+export default function Translate() {
+ const { source, schema, initial, targetLanguage } =
+ useLoaderData();
+ const { t } = useTranslation();
+
+ const sourceLanguage =
+ typeof source.language === 'string'
+ ? source.language
+ : ((source.language as { token?: string } | undefined)?.token ?? '—');
+
+ const fieldPlaceholders: Record = {};
+ for (const [field, value] of Object.entries(source)) {
+ if (typeof value === 'string' && value && field in schema.properties) {
+ fieldPlaceholders[field] = value;
+ }
+ }
+
+ return (
+
+ {t('cmsui.translate.source')} ({sourceLanguage}) —{' '}
+
+ {t('cmsui.translate.view_source')}
+
+
+ ),
+ blocks: hasVisibleSourceBlocks(source) ? (
+
+ ) : (
+ <>
+ {source.title}
+ {source.description && (
+ {source.description}
+ )}
+ >
+ ),
+ content: (
+
+ {schema.fieldsets.flatMap((fieldset) =>
+ fieldset.fields
+ .filter(
+ (field) =>
+ !['blocks', 'blocks_layout', 'changeNote'].includes(
+ field,
+ ) && formatValue(source[field as keyof Content]),
+ )
+ .map((field) => (
+
+
-
+ {schema.properties[field]?.title ?? field}
+
+ - {formatValue(source[field as keyof Content])}
+
+ )),
+ )}
+
+ ),
+ }}
+ heading={t('cmsui.translate.heading_new', {
+ title: source.title,
+ language: targetLanguage,
+ })}
+ submitMethod="post"
+ />
+ );
+}
+
+function formatValue(value: unknown): string {
+ if (value == null || value === '') return '';
+ if (typeof value === 'boolean') return value ? '✓' : '';
+ if (Array.isArray(value)) {
+ return value
+ .map((item) => formatValue(item))
+ .filter(Boolean)
+ .join(', ');
+ }
+ if (typeof value === 'object') {
+ const record = value as Record;
+ return String(record.title ?? record.token ?? '');
+ }
+ return String(value);
+}