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:
Erez Rokah
2019-11-14 11:25:04 +02:00
committed by GitHub
parent 4833f33728
commit 096b067d45
22 changed files with 660 additions and 191 deletions

View File

@ -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>
</>
);

View File

@ -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'] },

View File

@ -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',
},
},
};
}

View 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);
});
});

View 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);
});
});
});

View 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;
}

View File

@ -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];
}

View File

@ -0,0 +1 @@
export const selectLocale = state => state.get('locale', 'en');