feat: add translation support (#2870)
* feat: add translation support * test(cypress): fix locale import * docs: add locale documentation * feat: add german translation (#2877) * fix: locales package version, register all locales in netlify-cms
This commit is contained in:
35
packages/netlify-cms-core/src/bootstrap.js
vendored
35
packages/netlify-cms-core/src/bootstrap.js
vendored
@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Provider, connect } from 'react-redux';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { ConnectedRouter } from 'react-router-redux';
|
||||
import history from 'Routing/history';
|
||||
import store from 'ReduxStore';
|
||||
import { mergeConfig } from 'Actions/config';
|
||||
import { getPhrases } from 'Constants/defaultPhrases';
|
||||
import { getPhrases } from 'Lib/phrases';
|
||||
import { selectLocale } from 'Selectors/config';
|
||||
import { I18n } from 'react-polyglot';
|
||||
import { GlobalStyles } from 'netlify-cms-ui-default';
|
||||
import { ErrorBoundary } from 'UI';
|
||||
@ -17,6 +18,24 @@ import 'what-input';
|
||||
|
||||
const ROOT_ID = 'nc-root';
|
||||
|
||||
const TranslatedApp = ({ locale }) => {
|
||||
return (
|
||||
<I18n locale={locale} messages={getPhrases(locale)}>
|
||||
<ErrorBoundary showBackup>
|
||||
<ConnectedRouter history={history}>
|
||||
<Route component={App} />
|
||||
</ConnectedRouter>
|
||||
</ErrorBoundary>
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
const mapDispatchToProps = state => {
|
||||
return { locale: selectLocale(state.config) };
|
||||
};
|
||||
|
||||
const ConnectedTranslatedApp = connect(mapDispatchToProps)(TranslatedApp);
|
||||
|
||||
function bootstrap(opts = {}) {
|
||||
const { config } = opts;
|
||||
|
||||
@ -63,15 +82,9 @@ function bootstrap(opts = {}) {
|
||||
const Root = () => (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<I18n locale={'en'} messages={getPhrases()}>
|
||||
<ErrorBoundary showBackup>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<Route component={App} />
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
</ErrorBoundary>
|
||||
</I18n>
|
||||
<Provider store={store}>
|
||||
<ConnectedTranslatedApp />
|
||||
</Provider>
|
||||
</>
|
||||
);
|
||||
|
||||
|
@ -42,6 +42,7 @@ const getConfigSchema = () => ({
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
locale: { type: 'string', examples: ['en', 'fr', 'de'] },
|
||||
site_url: { type: 'string', examples: ['https://example.com'] },
|
||||
display_url: { type: 'string', examples: ['https://example.com'] },
|
||||
logo_url: { type: 'string', examples: ['https://example.com/images/logo.svg'] },
|
||||
|
@ -1,178 +0,0 @@
|
||||
export function getPhrases() {
|
||||
return {
|
||||
app: {
|
||||
header: {
|
||||
content: 'Contents',
|
||||
workflow: 'Workflow',
|
||||
media: 'Media',
|
||||
quickAdd: 'Quick add',
|
||||
},
|
||||
app: {
|
||||
errorHeader: 'Error loading the CMS configuration',
|
||||
configErrors: 'Config Errors',
|
||||
checkConfigYml: 'Check your config.yml file.',
|
||||
loadingConfig: 'Loading configuration...',
|
||||
waitingBackend: 'Waiting for backend...',
|
||||
},
|
||||
notFoundPage: {
|
||||
header: 'Not Found',
|
||||
},
|
||||
},
|
||||
collection: {
|
||||
sidebar: {
|
||||
collections: 'Collections',
|
||||
searchAll: 'Search all',
|
||||
},
|
||||
collectionTop: {
|
||||
viewAs: 'View as',
|
||||
newButton: 'New %{collectionLabel}',
|
||||
},
|
||||
entries: {
|
||||
loadingEntries: 'Loading Entries',
|
||||
cachingEntries: 'Caching Entries',
|
||||
longerLoading: 'This might take several minutes',
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
editorControlPane: {
|
||||
widget: {
|
||||
required: '%{fieldLabel} is required.',
|
||||
regexPattern: "%{fieldLabel} didn't match the pattern: %{pattern}.",
|
||||
processing: '%{fieldLabel} is processing.',
|
||||
range: '%{fieldLabel} must be between %{minValue} and %{maxValue}.',
|
||||
min: '%{fieldLabel} must be at least %{minValue}.',
|
||||
max: '%{fieldLabel} must be %{maxValue} or less.',
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
onLeavePage: 'Are you sure you want to leave this page?',
|
||||
onUpdatingWithUnsavedChanges:
|
||||
'You have unsaved changes, please save before updating status.',
|
||||
onPublishingNotReady: 'Please update status to "Ready" before publishing.',
|
||||
onPublishingWithUnsavedChanges: 'You have unsaved changes, please save before publishing.',
|
||||
onPublishing: 'Are you sure you want to publish this entry?',
|
||||
onDeleteWithUnsavedChanges:
|
||||
'Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?',
|
||||
onDeletePublishedEntry: 'Are you sure you want to delete this published entry?',
|
||||
onDeleteUnpublishedChangesWithUnsavedChanges:
|
||||
'This will delete all unpublished changes to this entry, as well as your unsaved changes from the current session. Do you still want to delete?',
|
||||
onDeleteUnpublishedChanges:
|
||||
'All unpublished changes to this entry will be deleted. Do you still want to delete?',
|
||||
loadingEntry: 'Loading entry...',
|
||||
confirmLoadBackup: 'A local backup was recovered for this entry, would you like to use it?',
|
||||
},
|
||||
editorToolbar: {
|
||||
publishing: 'Publishing...',
|
||||
publish: 'Publish',
|
||||
published: 'Published',
|
||||
publishAndCreateNew: 'Publish and create new',
|
||||
deleteUnpublishedChanges: 'Delete unpublished changes',
|
||||
deleteUnpublishedEntry: 'Delete unpublished entry',
|
||||
deletePublishedEntry: 'Delete published entry',
|
||||
deleteEntry: 'Delete entry',
|
||||
saving: 'Saving...',
|
||||
save: 'Save',
|
||||
deleting: 'Deleting...',
|
||||
updating: 'Updating...',
|
||||
setStatus: 'Set status',
|
||||
backCollection: ' Writing in %{collectionLabel} collection',
|
||||
unsavedChanges: 'Unsaved Changes',
|
||||
changesSaved: 'Changes saved',
|
||||
draft: 'Draft',
|
||||
inReview: 'In review',
|
||||
ready: 'Ready',
|
||||
publishNow: 'Publish now',
|
||||
deployPreviewPendingButtonLabel: 'Check for Preview',
|
||||
deployPreviewButtonLabel: 'View Preview',
|
||||
deployButtonLabel: 'View Live',
|
||||
},
|
||||
editorWidgets: {
|
||||
unknownControl: {
|
||||
noControl: "No control for widget '%{widget}'.",
|
||||
},
|
||||
unknownPreview: {
|
||||
noPreview: "No preview for widget '%{widget}'.",
|
||||
},
|
||||
},
|
||||
},
|
||||
mediaLibrary: {
|
||||
mediaLibrary: {
|
||||
onDelete: 'Are you sure you want to delete selected media?',
|
||||
},
|
||||
mediaLibraryModal: {
|
||||
loading: 'Loading...',
|
||||
noResults: 'No results.',
|
||||
noAssetsFound: 'No assets found.',
|
||||
noImagesFound: 'No images found.',
|
||||
private: 'Private ',
|
||||
images: 'Images',
|
||||
mediaAssets: 'Media assets',
|
||||
search: 'Search...',
|
||||
uploading: 'Uploading...',
|
||||
uploadNew: 'Upload new',
|
||||
deleting: 'Deleting...',
|
||||
deleteSelected: 'Delete selected',
|
||||
chooseSelected: 'Choose selected',
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
errorBoundary: {
|
||||
title: 'Error',
|
||||
details: "There's been an error - please ",
|
||||
reportIt: 'report it.',
|
||||
detailsHeading: 'Details',
|
||||
recoveredEntry: {
|
||||
heading: 'Recovered document',
|
||||
warning: 'Please copy/paste this somewhere before navigating away!',
|
||||
copyButtonLabel: 'Copy to clipboard',
|
||||
},
|
||||
},
|
||||
settingsDropdown: {
|
||||
logOut: 'Log Out',
|
||||
},
|
||||
toast: {
|
||||
onFailToLoadEntries: 'Failed to load entry: %{details}',
|
||||
onFailToLoadDeployPreview: 'Failed to load preview: %{details}',
|
||||
onFailToPersist: 'Failed to persist entry: %{details}',
|
||||
onFailToDelete: 'Failed to delete entry: %{details}',
|
||||
onFailToUpdateStatus: 'Failed to update status: %{details}',
|
||||
missingRequiredField:
|
||||
"Oops, you've missed a required field. Please complete before saving.",
|
||||
entrySaved: 'Entry saved',
|
||||
entryPublished: 'Entry published',
|
||||
onFailToPublishEntry: 'Failed to publish: %{details}',
|
||||
entryUpdated: 'Entry status updated',
|
||||
onDeleteUnpublishedChanges: 'Unpublished changes deleted',
|
||||
onFailToAuth: '%{details}',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
workflow: {
|
||||
loading: 'Loading Editorial Workflow Entries',
|
||||
workflowHeading: 'Editorial Workflow',
|
||||
newPost: 'New Post',
|
||||
description:
|
||||
'%{smart_count} entry waiting for review, %{readyCount} ready to go live. |||| %{smart_count} entries waiting for review, %{readyCount} ready to go live. ',
|
||||
},
|
||||
workflowCard: {
|
||||
lastChange: '%{date} by %{author}',
|
||||
lastChangeNoAuthor: '%{date}',
|
||||
lastChangeNoDate: 'by %{author}',
|
||||
deleteChanges: 'Delete changes',
|
||||
deleteNewEntry: 'Delete new entry',
|
||||
publishChanges: 'Publish changes',
|
||||
publishNewEntry: 'Publish new entry',
|
||||
},
|
||||
workflowList: {
|
||||
onDeleteEntry: 'Are you sure you want to delete this entry?',
|
||||
onPublishingNotReadyEntry:
|
||||
'Only items with a "Ready" status can be published. Please drag the card to the "Ready" column to enable publishing.',
|
||||
onPublishEntry: 'Are you sure you want to publish this entry?',
|
||||
draftHeader: 'Drafts',
|
||||
inReviewHeader: 'In Review',
|
||||
readyHeader: 'Ready',
|
||||
currentEntries: '%{smart_count} entry |||| %{smart_count} entries',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
119
packages/netlify-cms-core/src/lib/__tests__/phrases.spec.js
Normal file
119
packages/netlify-cms-core/src/lib/__tests__/phrases.spec.js
Normal file
@ -0,0 +1,119 @@
|
||||
import { getPhrases } from '../phrases';
|
||||
|
||||
jest.mock('../registry');
|
||||
|
||||
describe('defaultPhrases', () => {
|
||||
it('should merge en locale with given locale', () => {
|
||||
const { getLocale } = require('../registry');
|
||||
|
||||
const locales = {
|
||||
en: {
|
||||
app: {
|
||||
header: {
|
||||
content: 'Contents',
|
||||
workflow: 'Workflow',
|
||||
media: 'Media',
|
||||
quickAdd: 'Quick add',
|
||||
},
|
||||
app: {
|
||||
errorHeader: 'Error loading the CMS configuration',
|
||||
configErrors: 'Config Errors',
|
||||
checkConfigYml: 'Check your config.yml file.',
|
||||
loadingConfig: 'Loading configuration...',
|
||||
waitingBackend: 'Waiting for backend...',
|
||||
},
|
||||
notFoundPage: {
|
||||
header: 'Not Found',
|
||||
},
|
||||
},
|
||||
collection: {
|
||||
sidebar: {
|
||||
collections: 'Collections',
|
||||
searchAll: 'Search all',
|
||||
},
|
||||
collectionTop: {
|
||||
viewAs: 'View as',
|
||||
newButton: 'New %{collectionLabel}',
|
||||
},
|
||||
entries: {
|
||||
loadingEntries: 'Loading Entries',
|
||||
cachingEntries: 'Caching Entries',
|
||||
longerLoading: 'This might take several minutes',
|
||||
},
|
||||
},
|
||||
},
|
||||
de: {
|
||||
app: {
|
||||
header: {
|
||||
content: 'Inhalt',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
getLocale.mockImplementation(locale => locales[locale]);
|
||||
|
||||
expect(getPhrases('de')).toEqual({
|
||||
app: {
|
||||
header: {
|
||||
content: 'Inhalt',
|
||||
workflow: 'Workflow',
|
||||
media: 'Media',
|
||||
quickAdd: 'Quick add',
|
||||
},
|
||||
app: {
|
||||
errorHeader: 'Error loading the CMS configuration',
|
||||
configErrors: 'Config Errors',
|
||||
checkConfigYml: 'Check your config.yml file.',
|
||||
loadingConfig: 'Loading configuration...',
|
||||
waitingBackend: 'Waiting for backend...',
|
||||
},
|
||||
notFoundPage: {
|
||||
header: 'Not Found',
|
||||
},
|
||||
},
|
||||
collection: {
|
||||
sidebar: {
|
||||
collections: 'Collections',
|
||||
searchAll: 'Search all',
|
||||
},
|
||||
collectionTop: {
|
||||
viewAs: 'View as',
|
||||
newButton: 'New %{collectionLabel}',
|
||||
},
|
||||
entries: {
|
||||
loadingEntries: 'Loading Entries',
|
||||
cachingEntries: 'Caching Entries',
|
||||
longerLoading: 'This might take several minutes',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not mutate default phrases', () => {
|
||||
const { getLocale } = require('../registry');
|
||||
|
||||
const locales = {
|
||||
en: {
|
||||
app: {
|
||||
header: {
|
||||
content: 'Contents',
|
||||
},
|
||||
},
|
||||
},
|
||||
de: {
|
||||
app: {
|
||||
header: {
|
||||
content: 'Inhalt',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
getLocale.mockImplementation(locale => locales[locale]);
|
||||
|
||||
const result = getPhrases('de');
|
||||
|
||||
expect(result === locales['en']).toBe(false);
|
||||
});
|
||||
});
|
42
packages/netlify-cms-core/src/lib/__tests__/registry.spec.js
Normal file
42
packages/netlify-cms-core/src/lib/__tests__/registry.spec.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { registerLocale, getLocale } from '../registry';
|
||||
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
describe('registry', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('registerLocale', () => {
|
||||
it('should log error when name is empty', () => {
|
||||
registerLocale();
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"Locale parameters invalid. example: CMS.registerLocale('locale', phrases)",
|
||||
);
|
||||
});
|
||||
|
||||
it('should log error when phrases are undefined', () => {
|
||||
registerLocale('fr');
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"Locale parameters invalid. example: CMS.registerLocale('locale', phrases)",
|
||||
);
|
||||
});
|
||||
|
||||
it('should register locale', () => {
|
||||
const phrases = {
|
||||
app: {
|
||||
header: {
|
||||
content: 'Inhalt',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerLocale('de', phrases);
|
||||
|
||||
expect(getLocale('de')).toBe(phrases);
|
||||
});
|
||||
});
|
||||
});
|
7
packages/netlify-cms-core/src/lib/phrases.js
Normal file
7
packages/netlify-cms-core/src/lib/phrases.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { merge } from 'lodash';
|
||||
import { getLocale } from './registry';
|
||||
|
||||
export function getPhrases(locale) {
|
||||
const phrases = merge({}, getLocale('en'), getLocale(locale));
|
||||
return phrases;
|
||||
}
|
@ -13,6 +13,7 @@ const registry = {
|
||||
editorComponents: Map(),
|
||||
widgetValueSerializers: {},
|
||||
mediaLibraries: [],
|
||||
locales: {},
|
||||
};
|
||||
|
||||
export default {
|
||||
@ -31,6 +32,8 @@ export default {
|
||||
getBackend,
|
||||
registerMediaLibrary,
|
||||
getMediaLibrary,
|
||||
registerLocale,
|
||||
getLocale,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -156,3 +159,18 @@ export function registerMediaLibrary(mediaLibrary, options) {
|
||||
export function getMediaLibrary(name) {
|
||||
return registry.mediaLibraries.find(ml => ml.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locales
|
||||
*/
|
||||
export function registerLocale(locale, phrases) {
|
||||
if (!locale || !phrases) {
|
||||
console.error("Locale parameters invalid. example: CMS.registerLocale('locale', phrases)");
|
||||
} else {
|
||||
registry.locales[locale] = phrases;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocale(locale) {
|
||||
return registry.locales[locale];
|
||||
}
|
||||
|
1
packages/netlify-cms-core/src/selectors/config.js
Normal file
1
packages/netlify-cms-core/src/selectors/config.js
Normal file
@ -0,0 +1 @@
|
||||
export const selectLocale = state => state.get('locale', 'en');
|
Reference in New Issue
Block a user