feat: i18n support (#387)

This commit is contained in:
Daniel Lautzenheiser 2023-01-18 15:08:40 -05:00 committed by GitHub
parent 7372c3735b
commit a01f30ef69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 580 additions and 425 deletions

View File

@ -0,0 +1,8 @@
---
description: Kaffee ist ein kleiner Baum oder Strauch, der in seiner wilden Form
im Unterholz des Waldes wächst und traditionell kommerziell unter anderen
Bäumen angebaut wurde, die Schatten spendeten. Die waldähnliche Struktur
schattiger Kaffeefarmen bietet Lebensraum für eine Vielzahl von wandernden und
ansässigen Arten.
date: 2023-01-18T14:41:54.252-05:00
---

View File

@ -0,0 +1,8 @@
---
slug: file1
description: Coffee is a small tree or shrub that grows in the forest understory
in its wild form, and traditionally was grown commercially under other trees
that provided shade. The forest-like structure of shade coffee farms provides
habitat for a great number of migratory and resident species.
date: 2023-01-18T14:41:54.252-05:00
---

View File

@ -0,0 +1,8 @@
---
description: Le café est un petit arbre ou un arbuste qui pousse dans le
sous-étage de la forêt sous sa forme sauvage et qui était traditionnellement
cultivé commercialement sous d\'autres arbres qui fournissaient de l\'ombre.
La structure forestière des plantations de café d\'ombre fournit un habitat à
un grand nombre d\'espèces migratrices et résidentes.
date: 2023-01-18T14:41:54.252-05:00
---

View File

@ -7,6 +7,19 @@ local_backend: true
media_folder: /packages/core/dev-test/backends/proxy/assets/upload media_folder: /packages/core/dev-test/backends/proxy/assets/upload
public_folder: /backends/proxy/assets/upload public_folder: /backends/proxy/assets/upload
i18n:
# Required and can be one of multiple_folders, multiple_files or single_file
# multiple_folders - persists files in `<folder>/<locale>/<slug>.<extension>`
# multiple_files - persists files in `<folder>/<slug>.<locale>.<extension>`
# single_file - persists a single file in `<folder>/<slug>.<extension>`
structure: multiple_files
# Required - a list of locales to show in the editor UI
locales: [en, de, fr]
# Optional, defaults to the first item in locales.
# The locale to be used for fields validation and as a baseline for the entry.
defaultLocale: en
collections: collections:
- name: posts - name: posts
label: Posts label: Posts
@ -469,3 +482,24 @@ collections:
- label: File - label: File
name: file name: file
widget: file widget: file
- name: i18n_playground
label: i18n Playground
i18n: true
folder: packages/core/dev-test/backends/proxy/_i18n_playground
identifier_field: slug
create: true
fields:
# The slug field will be omitted from the translation.
- name: slug
label: Slug
widget: string
# same as 'i18n: translate'. Allows translation of the description field
- name: description
label: Description
widget: text
i18n: true
# The date field will be duplicated from the default locale.
- name: date
label: Date
widget: datetime
i18n: duplicate

View File

@ -3,6 +3,19 @@ backend:
site_url: 'https://example.com' site_url: 'https://example.com'
media_folder: assets/uploads media_folder: assets/uploads
locale: en locale: en
i18n:
# Required and can be one of multiple_folders, multiple_files or single_file
# multiple_folders - persists files in `<folder>/<locale>/<slug>.<extension>`
# multiple_files - persists files in `<folder>/<slug>.<locale>.<extension>`
# single_file - persists a single file in `<folder>/<slug>.<extension>`
structure: multiple_files
# Required - a list of locales to show in the editor UI
locales: [en, de, fr]
# Optional, defaults to the first item in locales.
# The locale to be used for fields validation and as a baseline for the entry.
defaultLocale: en
collections: collections:
- name: posts - name: posts
label: Posts label: Posts
@ -1188,3 +1201,24 @@ collections:
- label: File - label: File
name: file name: file
widget: file widget: file
- name: i18n_playground
label: i18n Playground
i18n: true
folder: _i18n_playground
identifier_field: slug
create: true
fields:
# The slug field will be omitted from the translation.
- name: slug
label: Slug
widget: string
# same as 'i18n: translate'. Allows translation of the description field
- name: description
label: Description
widget: text
i18n: true
# The date field will be duplicated from the default locale.
- name: date
label: Date
widget: datetime
i18n: duplicate

File diff suppressed because one or more lines are too long

View File

@ -469,10 +469,14 @@ export function changeDraftField({
} as const; } as const;
} }
export function changeDraftFieldValidation(path: string, errors: FieldError[]) { export function changeDraftFieldValidation(
path: string,
errors: FieldError[],
i18n?: I18nSettings,
) {
return { return {
type: DRAFT_VALIDATION_ERRORS, type: DRAFT_VALIDATION_ERRORS,
payload: { path, errors }, payload: { path, errors, i18n },
} as const; } as const;
} }
@ -662,12 +666,12 @@ export function loadEntries(collection: Collection, page = 0) {
const backend = currentBackend(configState.config); const backend = currentBackend(configState.config);
const append = !!(page && !isNaN(page) && page > 0); console.log('Trying to load page', page);
const loadAllEntries = 'nested' in collection || hasI18n(collection);
const append = !!(page && !isNaN(page) && page > 0) && !loadAllEntries;
dispatch(entriesLoading(collection)); dispatch(entriesLoading(collection));
try { try {
const loadAllEntries = 'nested' in collection || hasI18n(collection);
const response: { const response: {
cursor?: Cursor; cursor?: Cursor;
pagination?: number; pagination?: number;
@ -683,7 +687,7 @@ export function loadEntries(collection: Collection, page = 0) {
? Cursor.create({ ? Cursor.create({
actions: ['next'], actions: ['next'],
meta: { usingOldPaginationAPI: true }, meta: { usingOldPaginationAPI: true },
data: { nextPage: page + 1 }, data: { nextPage: loadAllEntries ? -1 : page + 1 },
}) })
: Cursor.create(response.cursor), : Cursor.create(response.cursor),
}; };
@ -749,7 +753,12 @@ export function traverseCollectionCursor(collection: Collection, action: string)
// Handle cursors representing pages in the old, integer-based pagination API // Handle cursors representing pages in the old, integer-based pagination API
if (cursor.meta?.usingOldPaginationAPI ?? false) { if (cursor.meta?.usingOldPaginationAPI ?? false) {
return dispatch(loadEntries(collection, cursor.data!.nextPage as number)); const nextPage = (cursor.data!.nextPage as number) ?? -1;
if (nextPage < 0) {
return;
}
return dispatch(loadEntries(collection, nextPage));
} }
try { try {

View File

@ -92,31 +92,35 @@ const Editor = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [collection, createBackup, slug]); }, [collection, createBackup, slug]);
console.log('VERSION', version);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const handlePersistEntry = useCallback( const handlePersistEntry = useCallback(
async (opts: EditorPersistOptions = {}) => { (opts: EditorPersistOptions = {}) => {
const { createNew = false, duplicate = false } = opts; const { createNew = false, duplicate = false } = opts;
if (!entryDraft.entry) { if (!entryDraft.entry) {
return; return;
} }
try {
await persistEntry(collection);
setVersion(version + 1);
deleteBackup();
if (createNew) {
navigateToNewEntry(collection.name);
if (duplicate && entryDraft.entry) {
createDraftDuplicateFromEntry(entryDraft.entry);
}
}
// eslint-disable-next-line no-empty
} catch (e) {}
setSubmitted(true); setSubmitted(true);
setTimeout(async () => {
try {
await persistEntry(collection);
setVersion(version + 1);
deleteBackup();
if (createNew) {
navigateToNewEntry(collection.name);
if (duplicate && entryDraft.entry) {
createDraftDuplicateFromEntry(entryDraft.entry);
}
}
// eslint-disable-next-line no-empty
} catch (e) {}
}, 100);
}, },
[ [
collection, collection,

View File

@ -178,7 +178,7 @@ const EditorControl = ({
const [dirty, setDirty] = useState(!isEmpty(value)); const [dirty, setDirty] = useState(!isEmpty(value));
const fieldErrorsSelector = useMemo(() => selectFieldErrors(path), [path]); const fieldErrorsSelector = useMemo(() => selectFieldErrors(path, i18n), [i18n, path]);
const errors = useAppSelector(fieldErrorsSelector); const errors = useAppSelector(fieldErrorsSelector);
const hasErrors = (submitted || dirty) && Boolean(errors.length); const hasErrors = (submitted || dirty) && Boolean(errors.length);
@ -192,17 +192,17 @@ const EditorControl = ({
); );
useEffect(() => { useEffect(() => {
if (!dirty && !submitted) { if ((!dirty && !submitted) || isHidden) {
return; return;
} }
const validateValue = async () => { const validateValue = async () => {
const errors = await validate(field, value, widget, t); const errors = await validate(field, value, widget, t);
dispatch(changeDraftFieldValidation(path, errors)); dispatch(changeDraftFieldValidation(path, errors, i18n));
}; };
validateValue(); validateValue();
}, [dispatch, field, path, t, value, widget, dirty, submitted]); }, [dirty, dispatch, field, i18n, isHidden, path, submitted, t, value, widget]);
const handleChangeDraftField = useCallback( const handleChangeDraftField = useCallback(
(value: ValueOrNestedValue) => { (value: ValueOrNestedValue) => {

View File

@ -1,3 +1,4 @@
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
@ -7,7 +8,6 @@ import React, { useCallback, useMemo, useState } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { changeDraftField as changeDraftFieldAction } from '@staticcms/core/actions/entries'; import { changeDraftField as changeDraftFieldAction } from '@staticcms/core/actions/entries';
import confirm from '@staticcms/core/components/UI/Confirm';
import { import {
getI18nInfo, getI18nInfo,
getLocaleDataPath, getLocaleDataPath,
@ -18,6 +18,7 @@ import {
} from '@staticcms/core/lib/i18n'; } from '@staticcms/core/lib/i18n';
import EditorControl from './EditorControl'; import EditorControl from './EditorControl';
import type { ButtonProps } from '@mui/material/Button';
import type { import type {
Collection, Collection,
Entry, Entry,
@ -42,24 +43,55 @@ const ControlPaneContainer = styled('div')`
const LocaleRowWrapper = styled('div')` const LocaleRowWrapper = styled('div')`
display: flex; display: flex;
gap: 8px;
`;
const DefaultLocaleWrittingIn = styled('div')`
display: flex;
align-items: center;
height: 36.5px;
`; `;
interface LocaleDropdownProps { interface LocaleDropdownProps {
locales: string[]; locales: string[];
defaultLocale: string;
dropdownText: string; dropdownText: string;
onLocaleChange: (locale: string) => void; color: ButtonProps['color'];
canChangeLocale: boolean;
onLocaleChange?: (locale: string) => void;
} }
const LocaleDropdown = ({ locales, dropdownText, onLocaleChange }: LocaleDropdownProps) => { const LocaleDropdown = ({
locales,
defaultLocale,
dropdownText,
color,
canChangeLocale,
onLocaleChange,
}: LocaleDropdownProps) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => { const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
}, []); }, []);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setAnchorEl(null); setAnchorEl(null);
}, []); }, []);
const handleLocaleChange = useCallback(
(locale: string) => {
onLocaleChange?.(locale);
handleClose();
},
[handleClose, onLocaleChange],
);
if (!canChangeLocale) {
return <DefaultLocaleWrittingIn>{dropdownText}</DefaultLocaleWrittingIn>;
}
return ( return (
<div> <div>
<Button <Button
@ -68,6 +100,9 @@ const LocaleDropdown = ({ locales, dropdownText, onLocaleChange }: LocaleDropdow
aria-haspopup="true" aria-haspopup="true"
aria-expanded={open ? 'true' : undefined} aria-expanded={open ? 'true' : undefined}
onClick={handleClick} onClick={handleClick}
variant="contained"
endIcon={<KeyboardArrowDownIcon />}
color={color}
> >
{dropdownText} {dropdownText}
</Button> </Button>
@ -80,11 +115,17 @@ const LocaleDropdown = ({ locales, dropdownText, onLocaleChange }: LocaleDropdow
'aria-labelledby': 'basic-button', 'aria-labelledby': 'basic-button',
}} }}
> >
{locales.map(locale => ( {locales
<MenuItem key={locale} onClick={() => onLocaleChange(locale)}> .filter(locale => locale !== defaultLocale)
{locale} .map(locale => (
</MenuItem> <MenuItem
))} key={locale}
onClick={() => handleLocaleChange(locale)}
sx={{ minWidth: '80px' }}
>
{locale}
</MenuItem>
))}
</Menu> </Menu>
</div> </div>
); );
@ -110,8 +151,8 @@ const EditorControlPane = ({
fields, fields,
fieldsErrors, fieldsErrors,
submitted, submitted,
changeDraftField,
locale, locale,
canChangeLocale = false,
onLocaleChange, onLocaleChange,
t, t,
}: TranslatedProps<EditorControlPaneProps>) => { }: TranslatedProps<EditorControlPaneProps>) => {
@ -128,40 +169,6 @@ const EditorControlPane = ({
return undefined; return undefined;
}, [collection, locale]); }, [collection, locale]);
const copyFromOtherLocale = useCallback(
({ targetLocale }: { targetLocale?: string }) =>
async (sourceLocale: string) => {
if (!targetLocale) {
return;
}
if (
!(await confirm({
title: 'editor.editorControlPane.i18n.copyFromLocaleConfirmTitle',
body: {
key: 'editor.editorControlPane.i18n.copyFromLocaleConfirmBody',
options: { locale: sourceLocale.toUpperCase() },
},
}))
) {
return;
}
fields.forEach(field => {
if (isFieldTranslatable(field, targetLocale, sourceLocale)) {
const copyValue = getFieldValue(
field,
entry,
sourceLocale !== i18n?.defaultLocale,
sourceLocale,
);
changeDraftField({ path: field.name, field, value: copyValue, i18n });
}
});
},
[fields, entry, i18n, changeDraftField],
);
if (!collection || !fields) { if (!collection || !fields) {
return null; return null;
} }
@ -176,16 +183,14 @@ const EditorControlPane = ({
<LocaleRowWrapper> <LocaleRowWrapper>
<LocaleDropdown <LocaleDropdown
locales={i18n.locales} locales={i18n.locales}
defaultLocale={i18n.defaultLocale}
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', { dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
locale: locale?.toUpperCase(), locale: locale?.toUpperCase(),
})} })}
color="primary"
canChangeLocale={canChangeLocale}
onLocaleChange={onLocaleChange} onLocaleChange={onLocaleChange}
/> />
<LocaleDropdown
locales={i18n.locales.filter(l => l !== locale)}
dropdownText={t('editor.editorControlPane.i18n.copyFromLocale')}
onLocaleChange={copyFromOtherLocale({ targetLocale: locale })}
/>
</LocaleRowWrapper> </LocaleRowWrapper>
) : null} ) : null}
{fields.map(field => { {fields.map(field => {
@ -222,7 +227,8 @@ export interface EditorControlPaneOwnProps {
fieldsErrors: FieldsErrors; fieldsErrors: FieldsErrors;
submitted: boolean; submitted: boolean;
locale?: string; locale?: string;
onLocaleChange: (locale: string) => void; canChangeLocale?: boolean;
onLocaleChange?: (locale: string) => void;
} }
function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps) { function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps) {

View File

@ -31,15 +31,6 @@ const StyledSplitPane = styled('div')`
display: grid; display: grid;
grid-template-columns: min(864px, 50%) auto; grid-template-columns: min(864px, 50%) auto;
height: calc(100vh - 64px); height: calc(100vh - 64px);
> div:nth-of-type(2)::before {
content: '';
width: 2px;
height: calc(100vh - 64px);
position: relative;
background-color: rgb(223, 223, 227);
display: block;
}
`; `;
const NoPreviewContainer = styled('div')` const NoPreviewContainer = styled('div')`
@ -78,14 +69,23 @@ const PreviewPaneContainer = styled(
`, `,
); );
const ControlPaneContainer = styled(PreviewPaneContainer)` interface ControlPaneContainerProps {
padding: 24px 16px 16px; $hidden?: boolean;
position: relative; }
overflow-x: hidden;
display: flex; const ControlPaneContainer = styled(
align-items: flex-start; PreviewPaneContainer,
justify-content: center; transientOptions,
`; )<ControlPaneContainerProps>(
({ $hidden = false }) => `
padding: 24px 16px 16px;
position: relative;
overflow-x: hidden;
display: ${$hidden ? 'none' : 'flex'};
align-items: flex-start;
justify-content: center;
`,
);
const StyledViewControls = styled('div')` const StyledViewControls = styled('div')`
position: fixed; position: fixed;
@ -127,7 +127,7 @@ interface EditorInterfaceProps {
collection: Collection; collection: Collection;
fields: Field[] | undefined; fields: Field[] | undefined;
fieldsErrors: FieldsErrors; fieldsErrors: FieldsErrors;
onPersist: (opts?: EditorPersistOptions) => Promise<void>; onPersist: (opts?: EditorPersistOptions) => void;
onDelete: () => Promise<void>; onDelete: () => Promise<void>;
onDuplicate: () => void; onDuplicate: () => void;
showDelete: boolean; showDelete: boolean;
@ -177,23 +177,15 @@ const EditorInterface = ({
}, [loadScroll]); }, [loadScroll]);
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {}; const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
const [selectedLocale, setSelectedLocale] = useState(locales?.[0]); const [selectedLocale, setSelectedLocale] = useState<string>(locales?.[1] ?? 'en');
const switchToDefaultLocale = useCallback(() => {
if (hasI18n(collection)) {
const { defaultLocale } = getI18nInfo(collection);
setSelectedLocale(defaultLocale);
}
}, [collection]);
const handleOnPersist = useCallback( const handleOnPersist = useCallback(
async (opts: EditorPersistOptions = {}) => { async (opts: EditorPersistOptions = {}) => {
const { createNew = false, duplicate = false } = opts; const { createNew = false, duplicate = false } = opts;
await switchToDefaultLocale(); // await switchToDefaultLocale();
// TODO Trigger field validation on persist
// this.controlPaneRef.validate();
onPersist({ createNew, duplicate }); onPersist({ createNew, duplicate });
}, },
[onPersist, switchToDefaultLocale], [onPersist],
); );
const handleTogglePreview = useCallback(() => { const handleTogglePreview = useCallback(() => {
@ -237,37 +229,54 @@ const EditorInterface = ({
const collectionI18nEnabled = hasI18n(collection); const collectionI18nEnabled = hasI18n(collection);
const editor = ( const editor = (
<ControlPaneContainer id="control-pane" $overFlow> <ControlPaneContainer key={defaultLocale} id="control-pane">
<EditorControlPane <EditorControlPane
collection={collection} collection={collection}
entry={entry} entry={entry}
fields={fields} fields={fields}
fieldsErrors={fieldsErrors} fieldsErrors={fieldsErrors}
locale={selectedLocale} locale={defaultLocale}
onLocaleChange={handleLocaleChange}
submitted={submitted} submitted={submitted}
t={t} t={t}
/> />
</ControlPaneContainer> </ControlPaneContainer>
); );
const editorLocale = ( const editorLocale = useMemo(
<ControlPaneContainer $overFlow={!scrollSyncEnabled}> () =>
<EditorControlPane (locales ?? [])
collection={collection} .filter(locale => locale !== defaultLocale)
entry={entry} .map(locale => (
fields={fields} <ControlPaneContainer key={locale} $hidden={locale !== selectedLocale}>
fieldsErrors={fieldsErrors} <EditorControlPane
locale={locales?.[1]} collection={collection}
onLocaleChange={handleLocaleChange} entry={entry}
submitted={submitted} fields={fields}
t={t} fieldsErrors={fieldsErrors}
/> locale={locale}
</ControlPaneContainer> onLocaleChange={handleLocaleChange}
submitted={submitted}
canChangeLocale
t={t}
/>
</ControlPaneContainer>
)),
[
collection,
defaultLocale,
entry,
fields,
fieldsErrors,
handleLocaleChange,
locales,
selectedLocale,
submitted,
t,
],
); );
const previewEntry = collectionI18nEnabled const previewEntry = collectionI18nEnabled
? getPreviewEntry(entry, selectedLocale, defaultLocale) ? getPreviewEntry(entry, selectedLocale[0], defaultLocale)
: entry; : entry;
const editorWithPreview = ( const editorWithPreview = (
@ -291,7 +300,9 @@ const EditorInterface = ({
<div> <div>
<StyledSplitPane> <StyledSplitPane>
<ScrollSyncPane>{editor}</ScrollSyncPane> <ScrollSyncPane>{editor}</ScrollSyncPane>
<ScrollSyncPane>{editorLocale}</ScrollSyncPane> <ScrollSyncPane>
<>{editorLocale}</>
</ScrollSyncPane>
</StyledSplitPane> </StyledSplitPane>
</div> </div>
</ScrollSync> </ScrollSync>

View File

@ -44,18 +44,15 @@ export function getI18nFilesDepth(collection: Collection, depth: number) {
} }
export function isFieldTranslatable(field: Field, locale?: string, defaultLocale?: string) { export function isFieldTranslatable(field: Field, locale?: string, defaultLocale?: string) {
const isTranslatable = locale !== defaultLocale && field.i18n === I18N_FIELD.TRANSLATE; return locale !== defaultLocale && field.i18n === I18N_FIELD.TRANSLATE;
return isTranslatable;
} }
export function isFieldDuplicate(field: Field, locale?: string, defaultLocale?: string) { export function isFieldDuplicate(field: Field, locale?: string, defaultLocale?: string) {
const isDuplicate = locale !== defaultLocale && field.i18n === I18N_FIELD.DUPLICATE; return locale !== defaultLocale && field.i18n === I18N_FIELD.DUPLICATE;
return isDuplicate;
} }
export function isFieldHidden(field: Field, locale?: string, defaultLocale?: string) { export function isFieldHidden(field: Field, locale?: string, defaultLocale?: string) {
const isHidden = locale !== defaultLocale && field.i18n === I18N_FIELD.NONE; return locale !== defaultLocale && field.i18n === I18N_FIELD.NONE;
return isHidden;
} }
export function getLocaleDataPath(locale: string) { export function getLocaleDataPath(locale: string) {
@ -205,8 +202,15 @@ export function getI18nBackup(
if (!data) { if (!data) {
return acc; return acc;
} }
entry.data = data; return {
return { ...acc, [locale]: { raw: entryToRaw(entry) } }; ...acc,
[locale]: {
raw: entryToRaw({
...entry,
data,
}),
},
};
}, {} as Record<string, { raw: string }>); }, {} as Record<string, { raw: string }>);
return i18nBackup; return i18nBackup;
@ -386,9 +390,9 @@ export function duplicateI18nFields(
locales locales
.filter(l => l !== defaultLocale) .filter(l => l !== defaultLocale)
.forEach(l => { .forEach(l => {
entryDraft = get( entryDraft = set(
entryDraft, entryDraft,
['entry', ...getDataPath(l, defaultLocale), ...fieldPath], ['entry', ...getDataPath(l, defaultLocale), ...fieldPath].join('.'),
value, value,
); );
}); });
@ -414,8 +418,10 @@ export function getPreviewEntry(
if (!locale || locale === defaultLocale) { if (!locale || locale === defaultLocale) {
return entry; return entry;
} }
entry.data = entry.i18n?.[locale]?.data as EntryData; return {
return entry; ...entry,
data: entry.i18n?.[locale]?.data as EntryData,
};
} }
export function serializeI18n( export function serializeI18n(

View File

@ -94,9 +94,6 @@ const de: LocalePhrasesRoot = {
}, },
i18n: { i18n: {
writingInLocale: 'Aktuelle Sprache: %{locale}', writingInLocale: 'Aktuelle Sprache: %{locale}',
copyFromLocale: 'Aus anderer Sprache übernehmen',
copyFromLocaleConfirmBody:
'Wollen Sie wirklich die Daten aus der Sprache %{locale} übernehmen?\nAlle bishergen Inhalte werden überschrieben.',
}, },
}, },
editor: { editor: {

View File

@ -98,10 +98,6 @@ const en: LocalePhrasesRoot = {
}, },
i18n: { i18n: {
writingInLocale: 'Writing in %{locale}', writingInLocale: 'Writing in %{locale}',
copyFromLocale: 'Fill in from another locale',
copyFromLocaleConfirmTitle: 'Fill in data from locale',
copyFromLocaleConfirmBody:
'Do you want to fill in data from %{locale} locale?\nAll existing content will be overwritten.',
}, },
}, },
editor: { editor: {

View File

@ -95,9 +95,6 @@ const tr: LocalePhrasesRoot = {
}, },
i18n: { i18n: {
writingInLocale: '%{locale} için yazılıyor', writingInLocale: '%{locale} için yazılıyor',
copyFromLocale: 'Başka bir dilden doldurun',
copyFromLocaleConfirmBody:
'Verileri %{locale} dilinden mi doldurmak istiyorsun?\nVarolan bütün verilerin üzerine yazılacak.',
}, },
}, },
editor: { editor: {

View File

@ -181,6 +181,7 @@ function entries(
const payload = action.payload; const payload = action.payload;
const loadedEntries = payload.entries; const loadedEntries = payload.entries;
const page = payload.page; const page = payload.page;
const append = payload.append;
const entities = { const entities = {
...state.entities, ...state.entities,
@ -196,7 +197,9 @@ function entries(
pages[payload.collection] = { pages[payload.collection] = {
page: page ?? undefined, page: page ?? undefined,
ids: [...(pages[payload.collection]?.ids ?? []), ...loadedEntries.map(entry => entry.slug)], ids: append
? [...(pages[payload.collection]?.ids ?? []), ...loadedEntries.map(entry => entry.slug)]
: [...loadedEntries.map(entry => entry.slug)],
isFetching: false, isFetching: false,
}; };

View File

@ -162,10 +162,14 @@ function entryDraftReducer(
entry: set(newState.entry, `${dataPath.join('.')}.${path}`, value), entry: set(newState.entry, `${dataPath.join('.')}.${path}`, value),
}; };
console.log('BEFORE I18N', { ...newState.entry });
if (i18n) { if (i18n) {
newState = duplicateI18nFields(newState, field, i18n.locales, i18n.defaultLocale); newState = duplicateI18nFields(newState, field, i18n.locales, i18n.defaultLocale);
} }
console.log('AFTER I18N', { ...newState.entry });
const newData = get(newState.entry, dataPath) ?? {}; const newData = get(newState.entry, dataPath) ?? {};
return { return {
@ -175,12 +179,16 @@ function entryDraftReducer(
} }
case DRAFT_VALIDATION_ERRORS: { case DRAFT_VALIDATION_ERRORS: {
const { path, errors } = action.payload; const { path, errors, i18n } = action.payload;
const fieldsErrors = { ...state.fieldsErrors }; const fieldsErrors = { ...state.fieldsErrors };
const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data'];
const fullPath = `${dataPath.join('.')}.${path}`;
if (errors.length === 0) { if (errors.length === 0) {
delete fieldsErrors[path]; delete fieldsErrors[fullPath];
} else { } else {
fieldsErrors[path] = action.payload.errors; fieldsErrors[fullPath] = action.payload.errors;
} }
return { return {
...state, ...state,

View File

@ -1,9 +1,14 @@
/* eslint-disable import/prefer-default-export */ import { getDataPath } from '@staticcms/core/lib/i18n';
import type { I18nSettings } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store'; import type { RootState } from '@staticcms/core/store';
export const selectFieldErrors = (path: string) => (state: RootState) => { export const selectFieldErrors =
return state.entryDraft.fieldsErrors[path] ?? []; (path: string, i18n: I18nSettings | undefined) => (state: RootState) => {
}; const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data'];
const fullPath = `${dataPath.join('.')}.${path}`;
return state.entryDraft.fieldsErrors[fullPath] ?? [];
};
export function selectEditingDraft(state: RootState) { export function selectEditingDraft(state: RootState) {
return state.entryDraft.entry; return state.entryDraft.entry;

View File

@ -168,6 +168,7 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
label={label} label={label}
value={inputDate} value={inputDate}
onChange={handleChange} onChange={handleChange}
disabled={isDisabled}
renderInput={params => ( renderInput={params => (
<TextField <TextField
key="mobile-date-input" key="mobile-date-input"
@ -198,6 +199,7 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
inputFormat={inputFormat} inputFormat={inputFormat}
value={inputDate} value={inputDate}
onChange={handleChange} onChange={handleChange}
disabled={isDisabled}
renderInput={params => ( renderInput={params => (
<TextField <TextField
key="time-input" key="time-input"
@ -227,6 +229,7 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
label={label} label={label}
value={inputDate} value={inputDate}
onChange={handleChange} onChange={handleChange}
disabled={isDisabled}
renderInput={params => ( renderInput={params => (
<TextField <TextField
key="mobile-date-time-input" key="mobile-date-time-input"

View File

@ -10,276 +10,6 @@ Static CMS runs new functionality in an open beta format from time to time. That
**Use these features at your own risk.** **Use these features at your own risk.**
## i18n Support
The CMS can provide a side by side interface for authoring content in multiple languages.
Configuring the CMS for i18n support requires top level configuration, collection level configuration and field level configuration.
### Top level configuration
<CodeTabs>
```yaml
i18n:
# Required and can be one of multiple_folders, multiple_files or single_file
# multiple_folders - persists files in `<folder>/<locale>/<slug>.<extension>`
# multiple_files - persists files in `<folder>/<slug>.<locale>.<extension>`
# single_file - persists a single file in `<folder>/<slug>.<extension>`
structure: multiple_folders
# Required - a list of locales to show in the editor UI
locales: [en, de, fr]
# Optional, defaults to the first item in locales.
# The locale to be used for fields validation and as a baseline for the entry.
defaultLocale: en
```
```js
i18n: {
/**
* Required and can be one of multiple_folders, multiple_files or single_file
* multiple_folders - persists files in `<folder>/<locale>/<slug>.<extension>`
* multiple_files - persists files in `<folder>/<slug>.<locale>.<extension>`
* single_file - persists a single file in `<folder>/<slug>.<extension>`
*/
structure: 'multiple_folders',
// Required - a list of locales to show in the editor UI
locales: ['en', 'de', 'fr'],
/**
* Optional, defaults to the first item in locales.
* The locale to be used for fields validation and as a baseline for the entry.
*/
defaultLocale: 'en'
},
```
</CodeTabs>
### Collection level configuration
<CodeTabs>
```yaml
collections:
- name: i18n_content
# same as the top level, but all fields are optional and defaults to the top level
# can also be a boolean to accept the top level defaults
i18n: true
```
```js
collections: [
{
name: 'i18n_content',
/**
* same as the top level, but all fields are optional and defaults to the top level
* can also be a boolean to accept the top level defaults
*/
i18n: true
},
],
```
</CodeTabs>
When using a file collection, you must also enable i18n for each individual file:
<CodeTabs>
```yaml
collections:
- name: pages
label: Pages
# Configure i18n for this collection.
i18n:
structure: single_file
locales: [en, de, fr]
files:
- name: about
label: About Page
file: site/content/about.yml
# Enable i18n for this file.
i18n: true
fields:
- { label: Title, name: title, widget: string, i18n: true }
```
```js
collections: [
{
name: 'pages',
label: 'Pages',
// Configure i18n for this collection.
i18n: {
structure: 'single_file',
locales: ['en', 'de', 'fr']
},
files: [
{
name: 'about',
label: 'About Page',
file: 'site/content/about.yml',
// Enable i18n for this file.
i18n: true,
fields: [
{ label: 'Title', name: 'title', widget: 'string', i18n: true }
],
},
],
},
],
```
</CodeTabs>
### Field level configuration
<CodeTabs>
```yaml
fields:
- label: Title
name: title
widget: string
# same as 'i18n: translate'. Allows translation of the title field
i18n: true
- label: Date
name: date
widget: datetime
# The date field will be duplicated from the default locale.
i18n: duplicate
- label: Body
name: body
# The markdown field will be omitted from the translation.
widget: markdown
```
```js
fields: [
{
label: 'Title',
name: 'title',
widget: 'string',
// same as 'i18n: translate'. Allows translation of the title field
i18n: true
},
{
label: 'Date',
name: 'date',
widget: 'datetime',
// The date field will be duplicated from the default locale.
i18n: 'duplicate'
},
{
label: 'Body',
name: 'body',
// The markdown field will be omitted from the translation.
widget: 'markdown'
},
],
```
</CodeTabs>
Example configuration:
<CodeTabs>
```yaml
i18n:
structure: multiple_folders
locales: [en, de, fr]
collections:
- name: posts
label: Posts
folder: content/posts
create: true
i18n: true
fields:
- label: Title
name: title
widget: string
i18n: true
- label: Date
name: date
widget: datetime
i18n: duplicate
- label: Body
name: body
widget: markdown
```
```js
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de', 'fr']
},
collections: [
{
name: 'posts',
label: 'Posts',
folder: 'content/posts',
create: true,
i18n: true,
fields: [
{ label: 'Title', name: 'title', widget: 'string', i18n: true },
{ label: 'Date', name: 'date', widget: 'datetime', i18n: 'duplicate' },
{ label: 'Body', name: 'body', widget: 'markdown' },
],
},
],
```
</CodeTabs>
### Limitations
1. File collections support only `structure: single_file`.
2. List widgets only support `i18n: true`. `i18n` configuration on sub fields is ignored.
3. Object widgets only support `i18n: true` and `i18n` configuration should be done per field:
<CodeTabs>
```yaml
- label: 'Object'
name: 'object'
widget: 'object'
i18n: true
fields:
- { label: 'String', name: 'string', widget: 'string', i18n: true }
- { label: 'Date', name: 'date', widget: 'datetime', i18n: duplicate }
- { label: 'Boolean', name: 'boolean', widget: 'boolean', i18n: duplicate }
- {
label: 'Object',
name: 'object',
widget: 'object',
i18n: true,
field: { label: 'String', name: 'string', widget: 'string', i18n: duplicate },
}
```
```js
{
label: 'Object',
name: 'object',
widget: 'object',
i18n: true,
fields: [
{ label: 'String', name: 'string', widget: 'string', i18n: true },
{ label: 'Date', name: 'date', widget: 'datetime', i18n: 'duplicate' },
{ label: 'Boolean', name: 'boolean', widget: 'boolean', i18n: 'duplicate' },
{
label: 'Object',
name: 'object',
widget: 'object',
i18n: true,
field: { label: 'String', name: 'string', widget: 'string', i18n: 'duplicate' },
},
],
},
```
</CodeTabs>
## Folder Collections Path ## Folder Collections Path
See [Folder Collections Path](/docs/collection-types#folder-collections-path). See [Folder Collections Path](/docs/collection-types#folder-collections-path).

View File

@ -0,0 +1,274 @@
---
group: Collections
title: i18n Support
beta: true
weight: 30
---
The CMS can provide a side by side interface for authoring content in multiple languages.
Configuring the CMS for i18n support requires top level configuration, collection level configuration and field level configuration.
### Top level configuration
<CodeTabs>
```yaml
i18n:
# Required and can be one of multiple_folders, multiple_files or single_file
# multiple_folders - persists files in `<folder>/<locale>/<slug>.<extension>`
# multiple_files - persists files in `<folder>/<slug>.<locale>.<extension>`
# single_file - persists a single file in `<folder>/<slug>.<extension>`
structure: multiple_folders
# Required - a list of locales to show in the editor UI
locales: [en, de, fr]
# Optional, defaults to the first item in locales.
# The locale to be used for fields validation and as a baseline for the entry.
defaultLocale: en
```
```js
i18n: {
/**
* Required and can be one of multiple_folders, multiple_files or single_file
* multiple_folders - persists files in `<folder>/<locale>/<slug>.<extension>`
* multiple_files - persists files in `<folder>/<slug>.<locale>.<extension>`
* single_file - persists a single file in `<folder>/<slug>.<extension>`
*/
structure: 'multiple_folders',
// Required - a list of locales to show in the editor UI
locales: ['en', 'de', 'fr'],
/**
* Optional, defaults to the first item in locales.
* The locale to be used for fields validation and as a baseline for the entry.
*/
defaultLocale: 'en'
},
```
</CodeTabs>
### Collection level configuration
<CodeTabs>
```yaml
collections:
- name: i18n_content
# same as the top level, but all fields are optional and defaults to the top level
# can also be a boolean to accept the top level defaults
i18n: true
```
```js
collections: [
{
name: 'i18n_content',
/**
* same as the top level, but all fields are optional and defaults to the top level
* can also be a boolean to accept the top level defaults
*/
i18n: true
},
],
```
</CodeTabs>
When using a file collection, you must also enable i18n for each individual file:
<CodeTabs>
```yaml
collections:
- name: pages
label: Pages
# Configure i18n for this collection.
i18n:
structure: single_file
locales: [en, de, fr]
files:
- name: about
label: About Page
file: site/content/about.yml
# Enable i18n for this file.
i18n: true
fields:
- { label: Title, name: title, widget: string, i18n: true }
```
```js
collections: [
{
name: 'pages',
label: 'Pages',
// Configure i18n for this collection.
i18n: {
structure: 'single_file',
locales: ['en', 'de', 'fr']
},
files: [
{
name: 'about',
label: 'About Page',
file: 'site/content/about.yml',
// Enable i18n for this file.
i18n: true,
fields: [
{ label: 'Title', name: 'title', widget: 'string', i18n: true }
],
},
],
},
],
```
</CodeTabs>
### Field level configuration
<CodeTabs>
```yaml
fields:
- label: Title
name: title
widget: string
# same as 'i18n: translate'. Allows translation of the title field
i18n: true
- label: Date
name: date
widget: datetime
# The date field will be duplicated from the default locale.
i18n: duplicate
- label: Body
name: body
# The markdown field will be omitted from the translation.
widget: markdown
```
```js
fields: [
{
label: 'Title',
name: 'title',
widget: 'string',
// same as 'i18n: translate'. Allows translation of the title field
i18n: true
},
{
label: 'Date',
name: 'date',
widget: 'datetime',
// The date field will be duplicated from the default locale.
i18n: 'duplicate'
},
{
label: 'Body',
name: 'body',
// The markdown field will be omitted from the translation.
widget: 'markdown'
},
],
```
</CodeTabs>
Example configuration:
<CodeTabs>
```yaml
i18n:
structure: multiple_folders
locales: [en, de, fr]
collections:
- name: posts
label: Posts
folder: content/posts
create: true
i18n: true
fields:
- label: Title
name: title
widget: string
i18n: true
- label: Date
name: date
widget: datetime
i18n: duplicate
- label: Body
name: body
widget: markdown
```
```js
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de', 'fr']
},
collections: [
{
name: 'posts',
label: 'Posts',
folder: 'content/posts',
create: true,
i18n: true,
fields: [
{ label: 'Title', name: 'title', widget: 'string', i18n: true },
{ label: 'Date', name: 'date', widget: 'datetime', i18n: 'duplicate' },
{ label: 'Body', name: 'body', widget: 'markdown' },
],
},
],
```
</CodeTabs>
### Limitations
1. File collections support only `structure: single_file`.
2. List widgets only support `i18n: true`. `i18n` configuration on sub fields is ignored.
3. Object widgets only support `i18n: true` and `i18n` configuration should be done per field:
<CodeTabs>
```yaml
- label: 'Object'
name: 'object'
widget: 'object'
i18n: true
fields:
- { label: 'String', name: 'string', widget: 'string', i18n: true }
- { label: 'Date', name: 'date', widget: 'datetime', i18n: duplicate }
- { label: 'Boolean', name: 'boolean', widget: 'boolean', i18n: duplicate }
- {
label: 'Object',
name: 'object',
widget: 'object',
i18n: true,
field: { label: 'String', name: 'string', widget: 'string', i18n: duplicate },
}
```
```js
{
label: 'Object',
name: 'object',
widget: 'object',
i18n: true,
fields: [
{ label: 'String', name: 'string', widget: 'string', i18n: true },
{ label: 'Date', name: 'date', widget: 'datetime', i18n: 'duplicate' },
{ label: 'Boolean', name: 'boolean', widget: 'boolean', i18n: 'duplicate' },
{
label: 'Object',
name: 'object',
widget: 'object',
i18n: true,
field: { label: 'String', name: 'string', widget: 'string', i18n: 'duplicate' },
},
],
},
```
</CodeTabs>

View File

@ -1,6 +1,6 @@
[build] [build]
command = "yarn build" ignore = "exit 0"
publish = ".next"
[[plugins]] [[plugins]]
package = "@netlify/plugin-nextjs" package = "@netlify/plugin-nextjs"