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
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:
- name: posts
label: Posts
@ -469,3 +482,24 @@ collections:
- label: File
name: 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'
media_folder: assets/uploads
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:
- name: posts
label: Posts
@ -1188,3 +1201,24 @@ collections:
- label: File
name: 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;
}
export function changeDraftFieldValidation(path: string, errors: FieldError[]) {
export function changeDraftFieldValidation(
path: string,
errors: FieldError[],
i18n?: I18nSettings,
) {
return {
type: DRAFT_VALIDATION_ERRORS,
payload: { path, errors },
payload: { path, errors, i18n },
} as const;
}
@ -662,12 +666,12 @@ export function loadEntries(collection: Collection, page = 0) {
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));
try {
const loadAllEntries = 'nested' in collection || hasI18n(collection);
const response: {
cursor?: Cursor;
pagination?: number;
@ -683,7 +687,7 @@ export function loadEntries(collection: Collection, page = 0) {
? Cursor.create({
actions: ['next'],
meta: { usingOldPaginationAPI: true },
data: { nextPage: page + 1 },
data: { nextPage: loadAllEntries ? -1 : page + 1 },
})
: 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
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -94,9 +94,6 @@ const de: LocalePhrasesRoot = {
},
i18n: {
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: {

View File

@ -98,10 +98,6 @@ const en: LocalePhrasesRoot = {
},
i18n: {
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: {

View File

@ -95,9 +95,6 @@ const tr: LocalePhrasesRoot = {
},
i18n: {
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: {

View File

@ -181,6 +181,7 @@ function entries(
const payload = action.payload;
const loadedEntries = payload.entries;
const page = payload.page;
const append = payload.append;
const entities = {
...state.entities,
@ -196,7 +197,9 @@ function entries(
pages[payload.collection] = {
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,
};

View File

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

View File

@ -168,6 +168,7 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
label={label}
value={inputDate}
onChange={handleChange}
disabled={isDisabled}
renderInput={params => (
<TextField
key="mobile-date-input"
@ -198,6 +199,7 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
inputFormat={inputFormat}
value={inputDate}
onChange={handleChange}
disabled={isDisabled}
renderInput={params => (
<TextField
key="time-input"
@ -227,6 +229,7 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
label={label}
value={inputDate}
onChange={handleChange}
disabled={isDisabled}
renderInput={params => (
<TextField
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.**
## 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
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]
command = "yarn build"
publish = ".next"
ignore = "exit 0"
[[plugins]]
package = "@netlify/plugin-nextjs"