allow manual initialization and config as an arg

This commit is contained in:
Shawn Erquhart 2018-02-28 15:45:16 -05:00
parent 95b6d8a884
commit a83c04cad0
15 changed files with 722 additions and 424 deletions

View File

@ -43,7 +43,6 @@
"setupFiles": [
"./setupTests.js"
],
"mapCoverage": true,
"coverageReporters": [
"lcov"
],
@ -77,7 +76,7 @@
"babel": "^6.5.2",
"babel-cli": "^6.18.0",
"babel-core": "^6.23.1",
"babel-jest": "^21.2.0",
"babel-jest": "^22.0.0",
"babel-loader": "^7.0.0",
"babel-plugin-lodash": "^3.2.0",
"babel-plugin-module-resolver": "^3.0.0",
@ -104,8 +103,8 @@
"file-loader": "^1.1.4",
"identity-obj-proxy": "^3.0.0",
"imports-loader": "^0.7.1",
"jest": "^21.2.1",
"jest-cli": "^21.2.1",
"jest": "^22.0.0",
"jest-cli": "^22.0.0",
"lint-staged": "^3.3.1",
"npm-check": "^5.2.3",
"postcss-cssnext": "^3.0.2",

View File

@ -1,61 +1,64 @@
import { fromJS } from 'immutable';
import { applyDefaults, validateConfig } from '../config';
describe('config', () => {
describe('applyDefaults', () => {
it('should set publish_mode if not set', () => {
expect(applyDefaults({
const config = fromJS({
foo: 'bar',
media_folder: 'path/to/media',
})).toEqual({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
expect(
applyDefaults(config)
).toEqual(
config.set('publish_mode', 'simple')
);
});
it('should set publish_mode from config', () => {
expect(applyDefaults({
foo: 'bar',
publish_mode: 'complex',
media_folder: 'path/to/media',
})).toEqual({
const config = fromJS({
foo: 'bar',
publish_mode: 'complex',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
expect(
applyDefaults(config)
).toEqual(
config
);
});
it('should set public_folder based on media_folder if not set', () => {
expect(applyDefaults({
expect(applyDefaults(fromJS({
foo: 'bar',
media_folder: 'path/to/media',
})).toEqual({
}))).toEqual(fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
}));
});
it('should not overwrite public_folder if set', () => {
expect(applyDefaults({
expect(applyDefaults(fromJS({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/publib/path',
})).toEqual({
}))).toEqual(fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/publib/path',
});
}));
});
});
describe('validateConfig', () => {
it('should return the config if no errors', () => {
const config = { foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [{}] };
const config = fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [{}] });
expect(
validateConfig(config)
).toEqual(config);
@ -63,55 +66,55 @@ describe('config', () => {
it('should throw if backend is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar' });
validateConfig(fromJS({ foo: 'bar' }));
}).toThrowError('Error in configuration file: A `backend` wasn\'t found. Check your config.yml file.');
});
it('should throw if backend name is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: {} });
validateConfig(fromJS({ foo: 'bar', backend: {} }));
}).toThrowError('Error in configuration file: A `backend.name` wasn\'t found. Check your config.yml file.');
});
it('should throw if backend name is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: { } } });
validateConfig(fromJS({ foo: 'bar', backend: { name: { } } }));
}).toThrowError('Error in configuration file: Your `backend.name` must be a string. Check your config.yml file.');
});
it('should throw if media_folder is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' } });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' } }));
}).toThrowError('Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.');
});
it('should throw if media_folder is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: {} });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: {} }));
}).toThrowError('Error in configuration file: Your `media_folder` must be a string. Check your config.yml file.');
});
it('should throw if collections is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' }));
}).toThrowError('Error in configuration file: A `collections` wasn\'t found. Check your config.yml file.');
});
it('should throw if collections not an array in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
it('should throw if collections is an empty array in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
it('should throw if collections is an array with a single null element in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [null] });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [null] }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
});

View File

@ -1,50 +1,63 @@
import yaml from "js-yaml";
import { set, defaultsDeep, get } from "lodash";
import { Map, List, fromJS } from "immutable";
import { trimStart, flow } from "lodash";
import { authenticateUser } from "Actions/auth";
import * as publishModes from "Constants/publishModes";
export const CONFIG_REQUEST = "CONFIG_REQUEST";
export const CONFIG_SUCCESS = "CONFIG_SUCCESS";
export const CONFIG_FAILURE = "CONFIG_FAILURE";
export const CONFIG_MERGE = "CONFIG_MERGE";
const defaults = {
publish_mode: publishModes.SIMPLE,
};
export function applyDefaults(config) {
// Make sure there is a public folder
set(defaults,
"public_folder",
config.media_folder.charAt(0) === "/" ? config.media_folder : `/${ config.media_folder }`);
return defaultsDeep(config, defaults);
return Map(defaults)
.mergeDeep(config)
.withMutations(map => {
/**
* Use media_folder as default public_folder.
*/
const defaultPublicFolder = `/${trimStart(map.get('media_folder'), '/')}`;
if (!map.get('public_folder')) {
map.set('public_folder', defaultPublicFolder);
}
});
}
export function validateConfig(config) {
if (!get(config, 'backend')) {
if (!config.get('backend')) {
throw new Error("Error in configuration file: A `backend` wasn't found. Check your config.yml file.");
}
if (!get(config, ['backend', 'name'])) {
if (!config.getIn(['backend', 'name'])) {
throw new Error("Error in configuration file: A `backend.name` wasn't found. Check your config.yml file.");
}
if (typeof config.backend.name !== 'string') {
if (typeof config.getIn(['backend', 'name']) !== 'string') {
throw new Error("Error in configuration file: Your `backend.name` must be a string. Check your config.yml file.");
}
if (!get(config, 'media_folder')) {
if (!config.get('media_folder')) {
throw new Error("Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.");
}
if (typeof config.media_folder !== 'string') {
if (typeof config.get('media_folder') !== 'string') {
throw new Error("Error in configuration file: Your `media_folder` must be a string. Check your config.yml file.");
}
if (!get(config, 'collections')) {
if (!config.get('collections')) {
throw new Error("Error in configuration file: A `collections` wasn\'t found. Check your config.yml file.");
}
if (!Array.isArray(config.collections) || config.collections.length === 0 || !config.collections[0]) {
const collections = config.get('collections');
if (!List.isList(collections) || collections.isEmpty() || !collections.first()) {
throw new Error("Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.");
}
return config;
}
function mergePreloadedConfig(preloadedConfig, loadedConfig) {
const map = fromJS(loadedConfig) || Map();
return preloadedConfig ? preloadedConfig.mergeDeep(map) : map;
}
function parseConfig(data) {
const config = yaml.safeLoad(data);
if (typeof CMS_ENV === "string" && config[CMS_ENV]) {
@ -82,29 +95,40 @@ export function configDidLoad(config) {
};
}
export function mergeConfig(config) {
return { type: CONFIG_MERGE, payload: config };
}
export function loadConfig() {
if (window.CMS_CONFIG) {
return configDidLoad(window.CMS_CONFIG);
return configDidLoad(fromJS(window.CMS_CONFIG));
}
return (dispatch) => {
return async (dispatch, getState) => {
dispatch(configLoading());
fetch("config.yml", { credentials: 'same-origin' })
.then((response) => {
if (response.status !== 200) {
try {
const preloadedConfig = getState().config;
const response = await fetch('config.yml', { credentials: 'same-origin' })
const requestSuccess = response.status === 200;
if (!preloadedConfig && !requestSuccess) {
throw new Error(`Failed to load config.yml (${ response.status })`);
}
return response.text();
})
.then(parseConfig)
.then(validateConfig)
.then(applyDefaults)
.then((config) => {
const loadedConfig = parseConfig(requestSuccess ? await response.text() : '');
/**
* Merge any existing configuration so the result can be validated.
*/
const mergedConfig = mergePreloadedConfig(preloadedConfig, loadedConfig)
const config = flow(validateConfig, applyDefaults)(mergedConfig);
dispatch(configDidLoad(config));
dispatch(authenticateUser());
})
.catch((err) => {
}
catch(err) {
dispatch(configFailed(err));
});
throw(err)
}
};
}

67
src/bootstrap.js vendored Normal file
View File

@ -0,0 +1,67 @@
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { Route } from 'react-router-dom';
import { ConnectedRouter } from 'react-router-redux';
import history from 'Routing/history';
import configureStore from 'Redux/configureStore';
import { mergeConfig } from 'Actions/config';
import { setStore } from 'ValueObjects/AssetProxy';
import { ErrorBoundary } from 'UI'
import App from 'App/App';
import 'EditorWidgets';
import 'MarkdownPlugins';
import './index.css';
function bootstrap({ config }) {
/**
* Log the version number.
*/
console.log(`Netlify CMS version ${NETLIFY_CMS_VERSION}`);
/**
* Create mount element dynamically.
*/
const el = document.createElement('div');
el.id = 'nc-root';
document.body.appendChild(el);
/**
* Configure Redux store.
*/
const store = configureStore();
/**
* Dispatch config to store if received. This config will be merged into
* config.yml if it exists, and any portion that produces a conflict will be
* overwritten.
*/
if (config) {
store.dispatch(mergeConfig(config));
}
/**
* Pass initial state into AssetProxy factory.
*/
setStore(store);
/**
* Create connected root component.
*/
const Root = () => (
<ErrorBoundary>
<Provider store={store}>
<ConnectedRouter history={history}>
<Route component={App}/>
</ConnectedRouter>
</Provider>
</ErrorBoundary>
);
/**
* Render application root.
*/
render(<Root />, el);
}
export default bootstrap;

View File

@ -53,6 +53,7 @@ class App extends React.Component {
<div>
<p>The <code>config.yml</code> file could not be loaded or failed to parse properly.</p>
<p><strong>Error message:</strong> {config.get('error')}</p>
<p>Check your console for details.</p>
</div>
</div>);
}
@ -105,7 +106,6 @@ class App extends React.Component {
openMediaLibrary,
} = this.props;
if (config === null) {
return null;
}

View File

@ -1,54 +1,16 @@
/**
* This module provides a self-initializing CMS instance with API hooks added to
* the `window` object.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { Route } from 'react-router-dom';
import { ConnectedRouter } from 'react-router-redux';
import history from 'Routing/history';
import configureStore from 'Redux/configureStore';
import { setStore } from 'ValueObjects/AssetProxy';
import { ErrorBoundary } from 'UI'
import bootstrap from './bootstrap';
import registry from 'Lib/registry';
import App from 'App/App';
import 'EditorWidgets';
import 'MarkdownPlugins';
import './index.css';
import createReactClass from 'create-react-class';
/**
* Log the version number.
* Load the app.
*/
console.log(`Netlify CMS version ${NETLIFY_CMS_VERSION}`);
/**
* Create mount element dynamically.
*/
const el = document.createElement('div');
el.id = 'nc-root';
document.body.appendChild(el);
/**
* Configure Redux store.
*/
const store = configureStore();
setStore(store);
/**
* Create connected root component.
*/
const Root = () => (
<ErrorBoundary>
<Provider store={store}>
<ConnectedRouter history={history}>
<Route component={App}/>
</ConnectedRouter>
</Provider>
</ErrorBoundary>
);
/**
* Render application root.
*/
render(<Root />, el);
bootstrap();
/**
* Add extension hooks to global scope.

7
src/init.js Normal file
View File

@ -0,0 +1,7 @@
/**
* This module provides manual initialization and registry hooks.
*/
import bootstrap from './bootstrap';
import registry from 'Lib/registry';
export { bootstrap as init, registry };

View File

@ -13,7 +13,7 @@ describe('collections', () => {
it('should load the collections from the config', () => {
expect(
collections(undefined, configLoaded({
collections(undefined, configLoaded(fromJS({
collections: [
{
name: 'posts',
@ -21,7 +21,7 @@ describe('collections', () => {
fields: [{ name: 'title', widget: 'string' }],
},
],
}))
})))
).toEqual(
OrderedMap({
posts: fromJS({

View File

@ -1,21 +1,21 @@
import Immutable from 'immutable';
import { Map } from 'immutable';
import { configLoaded, configLoading, configFailed } from 'Actions/config';
import config from '../config';
import config from 'Reducers/config';
describe('config', () => {
it('should handle an empty state', () => {
expect(
config(undefined, {})
).toEqual(
null
Map({ isFetching: true })
);
});
it('should handle an update', () => {
expect(
config(Immutable.Map({ a: 'b', c: 'd' }), configLoaded({ a: 'changed', e: 'new' }))
config(Map({ a: 'b', c: 'd' }), configLoaded(Map({ a: 'changed', e: 'new' })))
).toEqual(
Immutable.Map({ a: 'changed', e: 'new' })
Map({ a: 'changed', e: 'new' })
);
});
@ -23,15 +23,15 @@ describe('config', () => {
expect(
config(undefined, configLoading())
).toEqual(
Immutable.Map({ isFetching: true })
Map({ isFetching: true })
);
});
it('should handle an error', () => {
expect(
config(Immutable.Map({ isFetching: true }), configFailed(new Error('Config could not be loaded')))
config(Map(), configFailed(new Error('Config could not be loaded')))
).toEqual(
Immutable.Map({ error: 'Error: Config could not be loaded' })
Map({ error: 'Error: Config could not be loaded' })
);
});
});

View File

@ -1,4 +1,4 @@
import { OrderedMap, fromJS } from 'immutable';
import { List } from 'immutable';
import { has, get, escapeRegExp } from 'lodash';
import consoleError from 'Lib/consoleError';
import { CONFIG_SUCCESS } from 'Actions/config';
@ -7,42 +7,50 @@ import { INFERABLE_FIELDS } from 'Constants/fieldInference';
import { formatByExtension, formatToExtension, supportedFormats, frontmatterFormats } from 'Formats/formats';
const collections = (state = null, action) => {
const configCollections = action.payload && action.payload.collections;
switch (action.type) {
case CONFIG_SUCCESS:
return OrderedMap().withMutations((map) => {
(configCollections || []).forEach((configCollection) => {
validateCollection(configCollection);
if (has(configCollection, 'folder')) {
configCollection.type = FOLDER; // eslint-disable-line no-param-reassign
} else if (has(configCollection, 'files')) {
configCollection.type = FILES; // eslint-disable-line no-param-reassign
const configCollections = action.payload ? action.payload.get('collections') : List();
configCollections.forEach(validateCollection)
return configCollections
.toOrderedMap()
.map(collection => {
if (collection.has('folder')) {
return collection.set('type', FOLDER);
}
map.set(configCollection.name, fromJS(configCollection));
});
});
if (collection.has('files')) {
return collection.set('type', FILES);
}
})
.mapKeys((key, collection) => collection.get('name'));
default:
return state;
}
};
function validateCollection(configCollection) {
const collectionName = get(configCollection, 'name');
if (!has(configCollection, 'folder') && !has(configCollection, 'files')) {
throw new Error(`Unknown collection type for collection "${ collectionName }". Collections can be either Folder based or File based.`);
}
if (has(configCollection, 'format') && !supportedFormats.includes(get(configCollection, 'format'))) {
throw new Error(`Unknown collection format for collection "${ collectionName }". Supported formats are ${ supportedFormats.join(',') }`);
}
if (!has(configCollection, 'format') && has(configCollection, 'extension') && !formatByExtension(get(configCollection, 'extension'))) {
// Cannot infer format from extension.
throw new Error(`Please set a format for collection "${ collectionName }". Supported formats are ${ supportedFormats.join(',') }`);
}
if (has(configCollection, 'frontmatter_delimiter') && !frontmatterFormats.includes(get(configCollection, 'format'))) {
// Cannot set custom delimiter without explicit and proper frontmatter format declaration
throw new Error(`Please set a proper frontmatter format for collection "${ collectionName }" to use a custom delimiter. Supported frontmatter formats are yaml-frontmatter, toml-frontmatter, and json-frontmatter.`);
}
const {
name,
folder,
files,
format,
extension,
frontmatter_delimiter: delimiter
} = configCollection.toJS();
if (!folder && !files) {
throw new Error(`Unknown collection type for collection "${name}". Collections can be either Folder based or File based.`);
}
if (format && !supportedFormats.includes(format)) {
throw new Error(`Unknown collection format for collection "${name}". Supported formats are ${supportedFormats.join(',')}`);
}
if (!format && extension && !formatByExtension(extension)) {
// Cannot infer format from extension.
throw new Error(`Please set a format for collection "${name}". Supported formats are ${supportedFormats.join(',')}`);
}
if (delimiter && !frontmatterFormats.includes(format)) {
// Cannot set custom delimiter without explicit and proper frontmatter format declaration
throw new Error(`Please set a proper frontmatter format for collection "${name}" to use a custom delimiter. Supported frontmatter formats are yaml-frontmatter, toml-frontmatter, and json-frontmatter.`);
}
}
const selectors = {

View File

@ -1,14 +1,21 @@
import Immutable from 'immutable';
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE } from 'Actions/config';
import { Map } from 'immutable';
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE, CONFIG_MERGE } from 'Actions/config';
const config = (state = null, action) => {
const config = (state = Map({ isFetching: true }), action) => {
switch (action.type) {
case CONFIG_MERGE:
return state.mergeDeep(action.payload);
case CONFIG_REQUEST:
return Immutable.Map({ isFetching: true });
return state.set('isFetching', true);
case CONFIG_SUCCESS:
return Immutable.fromJS(action.payload);
/**
* The loadConfig action merges any existing config into the loaded config
* before firing this action (so the resulting config can be validated),
* so we don't have to merge it here.
*/
return action.payload.delete('isFetching');
case CONFIG_FAILURE:
return Immutable.Map({ error: action.payload.toString() });
return Map({ error: action.payload.toString() });
default:
return state;
}

View File

@ -1,10 +1,10 @@
import { fromJS } from 'immutable';
import { fromJS, List } from 'immutable';
import { CONFIG_SUCCESS } from 'Actions/config';
const integrations = (state = null, action) => {
switch (action.type) {
case CONFIG_SUCCESS:
const integrations = action.payload.integrations || [];
const integrations = action.payload.get('integrations').toJS() || [];
const newState = integrations.reduce((acc, integration) => {
const { hooks, collections, provider, ...providerData } = integration;
acc.providers[provider] = { ...providerData };

View File

@ -13,6 +13,7 @@ module.exports = merge.smart(require('./webpack.base.js'), {
`webpack-dev-server/client?http://${ HOST }:${ PORT }/`,
'./index',
],
init: './init',
},
output: {
path: path.join(__dirname, 'dist'),

View File

@ -8,6 +8,7 @@ const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = merge.smart(require('./webpack.base.js'), {
entry: {
cms: './index',
init: './init',
},
output: {
path: path.join(__dirname, 'dist'),

767
yarn.lock

File diff suppressed because it is too large Load Diff