diff options
author | Samy Pessé <samypesse@gmail.com> | 2016-12-22 10:18:38 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-12-22 10:18:38 +0100 |
commit | 194ebc3da9641ff96f083f9d8ab43c2d27944f9a (patch) | |
tree | c50988f32ccf18df93ae7ab40be78e9459642818 /packages/gitbook-core | |
parent | 64ccb6b00b4b63fa0e516d4e35351275b34f8c07 (diff) | |
parent | 16af264360e48e8a833e9efa9ab8d194574dbc70 (diff) | |
download | gitbook-194ebc3da9641ff96f083f9d8ab43c2d27944f9a.zip gitbook-194ebc3da9641ff96f083f9d8ab43c2d27944f9a.tar.gz gitbook-194ebc3da9641ff96f083f9d8ab43c2d27944f9a.tar.bz2 |
Merge pull request #1543 from GitbookIO/dream
React for rendering website with plugins
Diffstat (limited to 'packages/gitbook-core')
66 files changed, 2314 insertions, 0 deletions
diff --git a/packages/gitbook-core/.babelrc b/packages/gitbook-core/.babelrc new file mode 100644 index 0000000..5f27bda --- /dev/null +++ b/packages/gitbook-core/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "react", "stage-2"] +} diff --git a/packages/gitbook-core/.gitignore b/packages/gitbook-core/.gitignore new file mode 100644 index 0000000..eed58d7 --- /dev/null +++ b/packages/gitbook-core/.gitignore @@ -0,0 +1 @@ +gitbook.core.min.js diff --git a/packages/gitbook-core/.npmignore b/packages/gitbook-core/.npmignore new file mode 100644 index 0000000..b9cde8e --- /dev/null +++ b/packages/gitbook-core/.npmignore @@ -0,0 +1,3 @@ +src +!lib +!dist diff --git a/packages/gitbook-core/package.json b/packages/gitbook-core/package.json new file mode 100644 index 0000000..182e6e3 --- /dev/null +++ b/packages/gitbook-core/package.json @@ -0,0 +1,51 @@ +{ + "name": "gitbook-core", + "version": "4.0.0", + "description": "Core for GitBook plugins API", + "main": "./lib/index.js", + "dependencies": { + "bluebird": "^3.4.6", + "classnames": "^2.2.5", + "entities": "^1.1.1", + "history": "^4.3.0", + "html-tags": "^1.1.1", + "immutable": "^3.8.1", + "mousetrap": "1.6.0", + "react": "15.4.1", + "react-addons-css-transition-group": "15.4.1", + "react-dom": "15.4.1", + "react-helmet": "^3.1.0", + "react-immutable-proptypes": "^2.1.0", + "react-intl": "^2.1.5", + "react-redux": "^4.4.5", + "react-safe-html": "0.4.0", + "redux": "^3.5.2", + "redux-thunk": "^2.1.0", + "reflexbox": "^2.2.2", + "whatwg-fetch": "^1.0.0" + }, + "devDependencies": { + "babel-cli": "^6.14.0", + "babel-preset-es2015": "^6.14.0", + "babel-preset-react": "^6.11.1", + "babel-preset-stage-2": "^6.13.0", + "browserify": "^13.1.0", + "envify": "^3.4.1", + "uglify-js": "^2.7.3" + }, + "scripts": { + "dist-lib": "rm -rf lib/ && babel -d lib/ src/", + "dist-standalone": "mkdir -p dist && browserify -r ./lib/index.js:gitbook-core -r react -r react-dom ./lib/index.js | uglifyjs -c > ./dist/gitbook.core.min.js", + "dist": "npm run dist-lib && npm run dist-standalone", + "prepublish": "npm run dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "author": "GitBook Inc. <contact@gitbook.com>", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + } +} diff --git a/packages/gitbook-core/src/actions/TYPES.js b/packages/gitbook-core/src/actions/TYPES.js new file mode 100644 index 0000000..9876057 --- /dev/null +++ b/packages/gitbook-core/src/actions/TYPES.js @@ -0,0 +1,16 @@ + +module.exports = { + // Components + REGISTER_COMPONENT: 'components/register', + UNREGISTER_COMPONENT: 'components/unregister', + // Navigation + HISTORY_ACTIVATE: 'history/activate', + HISTORY_DEACTIVATE: 'history/deactivate', + HISTORY_LISTEN: 'history/listen', + HISTORY_UPDATE: 'history/update', + PAGE_FETCH_START: 'history/fetch:start', + PAGE_FETCH_END: 'history/fetch:end', + PAGE_FETCH_ERROR: 'history/fetch:error', + // i18n + I18N_REGISTER_LOCALE: 'i18n/register:locale' +}; diff --git a/packages/gitbook-core/src/actions/components.js b/packages/gitbook-core/src/actions/components.js new file mode 100644 index 0000000..f21c382 --- /dev/null +++ b/packages/gitbook-core/src/actions/components.js @@ -0,0 +1,37 @@ +const ACTION_TYPES = require('./TYPES'); + +/** + * Find all components matching a descriptor + * @param {List<ComponentDescriptor>} state + * @param {String} matching.role + */ +function findMatchingComponents(state, matching) { + return state + .filter(({descriptor}) => { + if (matching.role && matching.role !== descriptor.role) { + return false; + } + + return true; + }) + .map(component => component.Component); +} + +/** + * Register a new component + * @param {React.Class} Component + * @param {Descriptor} descriptor + * @return {Action} + */ +function registerComponent(Component, descriptor) { + return { + type: ACTION_TYPES.REGISTER_COMPONENT, + Component, + descriptor + }; +} + +module.exports = { + findMatchingComponents, + registerComponent +}; diff --git a/packages/gitbook-core/src/actions/history.js b/packages/gitbook-core/src/actions/history.js new file mode 100644 index 0000000..1c33f4a --- /dev/null +++ b/packages/gitbook-core/src/actions/history.js @@ -0,0 +1,188 @@ +const ACTION_TYPES = require('./TYPES'); +const getPayload = require('../lib/getPayload'); +const Location = require('../models/Location'); + +const SUPPORTED = ( + typeof window !== 'undefined' && + window.history && window.history.pushState && window.history.replaceState && + // pushState isn't reliable on iOS until 5. + !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]\D|WebApps\/.+CFNetwork)/) +); + +/** + * Initialize the history + */ +function activate() { + return (dispatch, getState) => { + dispatch({ + type: ACTION_TYPES.HISTORY_ACTIVATE, + listener: (location) => { + location = Location.fromNative(location); + const prevLocation = getState().history.location; + + // Fetch page if required + if (!prevLocation || location.pathname !== prevLocation.pathname) { + dispatch(fetchPage(location.pathname)); + } + + // Signal location to listener + dispatch(emit()); + + // Update the location + dispatch({ + type: ACTION_TYPES.HISTORY_UPDATE, + location + }); + } + }); + + // Trigger for existing listeners + dispatch(emit()); + }; +} + +/** + * Emit current location + * @param {List|Array<Function>} to? + */ +function emit(to) { + return (dispatch, getState) => { + const { listeners, client } = getState().history; + + if (!client) { + return; + } + + const location = Location.fromNative(client.location); + + to = to || listeners; + + to.forEach(handler => { + handler(location, dispatch, getState); + }); + }; +} + +/** + * Cleanup the history + */ +function deactivate() { + return { type: ACTION_TYPES.HISTORY_DEACTIVATE }; +} + +/** + * Push a new url into the history + * @param {String|Location} location + * @return {Action} action + */ +function push(location) { + return (dispatch, getState) => { + const { client } = getState().history; + location = Location.fromNative(location); + + if (SUPPORTED) { + client.push(location.toNative()); + } else { + redirect(location.toString()); + } + }; +} + +/** + * Replace current state in history + * @param {String|Location} location + * @return {Action} action + */ +function replace(location) { + return (dispatch, getState) => { + const { client } = getState().history; + location = Location.fromNative(location); + + if (SUPPORTED) { + client.replace(location.toNative()); + } else { + redirect(location.toString()); + } + }; +} + +/** + * Hard redirection + * @param {String} uri + * @return {Action} action + */ +function redirect(uri) { + return () => { + window.location.href = uri; + }; +} + +/** + * Listen to url change + * @param {Function} listener + * @return {Action} action + */ +function listen(listener) { + return (dispatch, getState) => { + dispatch({ type: ACTION_TYPES.HISTORY_LISTEN, listener }); + + // Trigger for existing listeners + dispatch(emit([ listener ])); + }; +} + +/** + * Fetch a new page and update the store accordingly + * @param {String} pathname + * @return {Action} action + */ +function fetchPage(pathname) { + return (dispatch, getState) => { + dispatch({ type: ACTION_TYPES.PAGE_FETCH_START }); + + window.fetch(pathname, { + credentials: 'include' + }) + .then( + response => { + return response.text(); + } + ) + .then( + html => { + const payload = getPayload(html); + + if (!payload) { + throw new Error('No payload found in page'); + } + + dispatch({ type: ACTION_TYPES.PAGE_FETCH_END, payload }); + } + ) + .catch( + error => { + // dispatch(redirect(pathname)); + dispatch({ type: ACTION_TYPES.PAGE_FETCH_ERROR, error }); + } + ); + }; +} + +/** + * Fetch a new article + * @param {SummaryArticle} article + * @return {Action} action + */ +function fetchArticle(article) { + return fetchPage(article.path); +} + +module.exports = { + activate, + deactivate, + listen, + push, + replace, + fetchPage, + fetchArticle +}; diff --git a/packages/gitbook-core/src/actions/i18n.js b/packages/gitbook-core/src/actions/i18n.js new file mode 100644 index 0000000..115c5a1 --- /dev/null +++ b/packages/gitbook-core/src/actions/i18n.js @@ -0,0 +1,33 @@ +const ACTION_TYPES = require('./TYPES'); + +/** + * Register messages for a locale + * @param {String} locale + * @param {Map<String:String>} messages + * @return {Action} + */ +function registerLocale(locale, messages) { + return { type: ACTION_TYPES.I18N_REGISTER_LOCALE, locale, messages }; +} + +/** + * Register multiple locales + * @param {Map<String:Object>} locales + * @return {Action} + */ +function registerLocales(locales) { + return (dispatch) => { + for (const locale in locales) { + if (!locales.hasOwnProperty(locale)) { + continue; + } + + dispatch(registerLocale(locale, locales[locale])); + } + }; +} + +module.exports = { + registerLocale, + registerLocales +}; diff --git a/packages/gitbook-core/src/components/Backdrop.js b/packages/gitbook-core/src/components/Backdrop.js new file mode 100644 index 0000000..7b34b0d --- /dev/null +++ b/packages/gitbook-core/src/components/Backdrop.js @@ -0,0 +1,56 @@ +const React = require('react'); +const HotKeys = require('./HotKeys'); + +/** + * Backdrop for modals, dropdown, etc. that covers the whole screen + * and handles click and pressing escape. + * + * <Backdrop onClose={onCloseModal} /> + */ +const Backdrop = React.createClass({ + propTypes: { + // Callback when backdrop is closed + onClose: React.PropTypes.func.isRequired, + // Z-index for the backdrop + zIndex: React.PropTypes.number, + children: React.PropTypes.node + }, + + getDefaultProps() { + return { + zIndex: 200 + }; + }, + + onClick(event) { + const { onClose } = this.props; + + event.preventDefault(); + event.stopPropagation(); + + onClose(); + }, + + render() { + const { zIndex, children, onClose } = this.props; + const style = { + zIndex, + position: 'fixed', + top: 0, + right: 0, + width: '100%', + height: '100%' + }; + const keyMap = { + 'escape': onClose + }; + + return ( + <HotKeys keyMap={keyMap}> + <div style={style} onClick={this.onClick}>{children}</div> + </HotKeys> + ); + } +}); + +module.exports = Backdrop; diff --git a/packages/gitbook-core/src/components/Button.js b/packages/gitbook-core/src/components/Button.js new file mode 100644 index 0000000..4d929b8 --- /dev/null +++ b/packages/gitbook-core/src/components/Button.js @@ -0,0 +1,22 @@ +const React = require('react'); +const classNames = require('classnames'); + +const Button = React.createClass({ + propTypes: { + active: React.PropTypes.bool, + className: React.PropTypes.string, + children: React.PropTypes.node, + onClick: React.PropTypes.func + }, + + render() { + const { children, active, onClick } = this.props; + const className = classNames('GitBook-Button', this.props.className, { + active + }); + + return <button className={className} onClick={onClick}>{children}</button>; + } +}); + +module.exports = Button; diff --git a/packages/gitbook-core/src/components/ButtonGroup.js b/packages/gitbook-core/src/components/ButtonGroup.js new file mode 100644 index 0000000..4c20b68 --- /dev/null +++ b/packages/gitbook-core/src/components/ButtonGroup.js @@ -0,0 +1,23 @@ +const React = require('react'); +const classNames = require('classnames'); + +const ButtonGroup = React.createClass({ + propTypes: { + className: React.PropTypes.string, + children: React.PropTypes.node, + onClick: React.PropTypes.func + }, + + render() { + let { className, children } = this.props; + + className = classNames( + 'GitBook-ButtonGroup', + className + ); + + return <div className={className}>{children}</div>; + } +}); + +module.exports = ButtonGroup; diff --git a/packages/gitbook-core/src/components/ContextProvider.js b/packages/gitbook-core/src/components/ContextProvider.js new file mode 100644 index 0000000..96a44e3 --- /dev/null +++ b/packages/gitbook-core/src/components/ContextProvider.js @@ -0,0 +1,34 @@ +const React = require('react'); +const { Provider } = require('react-redux'); + +const ContextShape = require('../propTypes/Context'); + +/** + * React component to provide a GitBook context to children components. + */ + +const ContextProvider = React.createClass({ + propTypes: { + context: ContextShape.isRequired, + children: React.PropTypes.node + }, + + childContextTypes: { + gitbook: ContextShape + }, + + getChildContext() { + const { context } = this.props; + + return { + gitbook: context + }; + }, + + render() { + const { context, children } = this.props; + return <Provider store={context.store}>{children}</Provider>; + } +}); + +module.exports = ContextProvider; diff --git a/packages/gitbook-core/src/components/Dropdown.js b/packages/gitbook-core/src/components/Dropdown.js new file mode 100644 index 0000000..83a377f --- /dev/null +++ b/packages/gitbook-core/src/components/Dropdown.js @@ -0,0 +1,126 @@ +const React = require('react'); +const classNames = require('classnames'); + +/** + * Dropdown to display a menu + * + * <Dropdown.Container> + * + * <Button /> + * + * <Dropdown.Menu> + * <Dropdown.Item href={...}> ... </Dropdown.Item> + * <Dropdown.Item onClick={...}> ... </Dropdown.Item> + * </Dropdown.Menu> + * </Dropdown.Container> + */ + +const DropdownContainer = React.createClass({ + propTypes: { + className: React.PropTypes.string, + children: React.PropTypes.node + }, + + render() { + let { className, children } = this.props; + + className = classNames( + 'GitBook-Dropdown', + className + ); + + return ( + <div className={className}> + {children} + </div> + ); + } +}); + +/** + * A dropdown item which can contains informations. + */ +const DropdownItem = React.createClass({ + propTypes: { + children: React.PropTypes.node + }, + + render() { + const { children } = this.props; + + return ( + <div className="GitBook-DropdownItem"> + {children} + </div> + ); + } +}); + + +/** + * A dropdown item, which is always a link. + */ +const DropdownItemLink = React.createClass({ + propTypes: { + children: React.PropTypes.node, + onClick: React.PropTypes.func, + href: React.PropTypes.string + }, + + onClick(event) { + const { onClick, href } = this.props; + + if (href) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (onClick) { + onClick(); + } + }, + + render() { + const { children, href, ...otherProps } = this.props; + + return ( + <a {...otherProps} className="GitBook-DropdownItemLink" href={href || '#'} onClick={this.onClick} > + {children} + </a> + ); + } +}); + + +/** + * A DropdownMenu to display DropdownItems. Must be inside a + * DropdownContainer. + */ +const DropdownMenu = React.createClass({ + propTypes: { + className: React.PropTypes.string, + children: React.PropTypes.node + }, + + render() { + let { className, children } = this.props; + className = classNames('GitBook-DropdownMenu', className); + + return ( + <div className={className}> + {children} + </div> + ); + } +}); + +const Dropdown = { + Item: DropdownItem, + ItemLink: DropdownItemLink, + Menu: DropdownMenu, + Container: DropdownContainer +}; + +module.exports = Dropdown; diff --git a/packages/gitbook-core/src/components/HTMLContent.js b/packages/gitbook-core/src/components/HTMLContent.js new file mode 100644 index 0000000..9d15398 --- /dev/null +++ b/packages/gitbook-core/src/components/HTMLContent.js @@ -0,0 +1,77 @@ +const React = require('react'); +const ReactSafeHtml = require('react-safe-html'); +const { DOMProperty } = require('react-dom/lib/ReactInjection'); +const htmlTags = require('html-tags'); +const entities = require('entities'); + +const { InjectedComponent } = require('./InjectedComponent'); + +DOMProperty.injectDOMPropertyConfig({ + Properties: { + align: DOMProperty.MUST_USE_ATTRIBUTE + }, + isCustomAttribute: (attributeName) => { + return attributeName === 'align'; + } +}); + +/* + HTMLContent is a container for the page HTML that parse the content and + render the right block. + All html elements can be extended using the injected component. + */ + +function inject(injectedProps, Component) { + return (props) => { + const cleanProps = { + ...props, + className: props['class'] + }; + delete cleanProps['class']; + + return ( + <InjectedComponent {...injectedProps(cleanProps)}> + <Component {...cleanProps} /> + </InjectedComponent> + ); + }; +} + +const COMPONENTS = { + // Templating blocks are exported as <xblock name="youtube" props="{}" /> + 'xblock': inject( + ({name, props}) => { + props = entities.decodeHTML(props); + return { + matching: { role: `block:${name}` }, + props: JSON.parse(props) + }; + }, + props => <div {...props} /> + ) +}; + +htmlTags.forEach(tag => { + COMPONENTS[tag] = inject( + props => { + return { + matching: { role: `html:${tag}` }, + props + }; + }, + props => React.createElement(tag, props) + ); +}); + +const HTMLContent = React.createClass({ + propTypes: { + html: React.PropTypes.string.isRequired + }, + + render() { + const { html } = this.props; + return <ReactSafeHtml html={html} components={COMPONENTS} />; + } +}); + +module.exports = HTMLContent; diff --git a/packages/gitbook-core/src/components/HotKeys.js b/packages/gitbook-core/src/components/HotKeys.js new file mode 100644 index 0000000..e2a8154 --- /dev/null +++ b/packages/gitbook-core/src/components/HotKeys.js @@ -0,0 +1,59 @@ +const React = require('react'); +const Mousetrap = require('mousetrap'); +const { Map } = require('immutable'); + +/** + * Defines hotkeys globally when this component is mounted. + * + * keyMap = { + * 'escape': (e) => quit() + * 'mod+s': (e) => save() + * } + * + * <HotKeys keyMap={keyMap}> + * < ... /> + * </HotKeys> + */ + +const HotKeys = React.createClass({ + propTypes: { + children: React.PropTypes.node.isRequired, + keyMap: React.PropTypes.objectOf(React.PropTypes.func) + }, + + getDefaultProps() { + return { keyMap: {} }; + }, + + updateBindings(keyMap) { + Map(keyMap).forEach((handler, key) => { + Mousetrap.bind(key, handler); + }); + }, + + clearBindings(keyMap) { + Map(keyMap).forEach((handler, key) => { + Mousetrap.unbind(key, handler); + }); + }, + + componentDidMount() { + this.updateBindings(this.props.keyMap); + }, + + componentDidUpdate(prevProps) { + this.clearBindings(prevProps.keyMap); + this.updateBindings(this.props.keyMap); + }, + + componentWillUnmount() { + this.clearBindings(this.props.keyMap); + }, + + render() { + // Simply render the only child + return React.Children.only(this.props.children); + } +}); + +module.exports = HotKeys; diff --git a/packages/gitbook-core/src/components/I18nProvider.js b/packages/gitbook-core/src/components/I18nProvider.js new file mode 100644 index 0000000..b6b2d0f --- /dev/null +++ b/packages/gitbook-core/src/components/I18nProvider.js @@ -0,0 +1,28 @@ +const { Map } = require('immutable'); +const React = require('react'); +const intl = require('react-intl'); +const ReactRedux = require('react-redux'); + +const I18nProvider = React.createClass({ + propTypes: { + children: React.PropTypes.node, + messages: React.PropTypes.object + }, + + render() { + let { messages } = this.props; + messages = messages.get('en', Map()).toJS(); + + return ( + <intl.IntlProvider locale={'en'} messages={messages}> + {this.props.children} + </intl.IntlProvider> + ); + } +}); + +const mapStateToProps = state => { + return { messages: state.i18n.messages }; +}; + +module.exports = ReactRedux.connect(mapStateToProps)(I18nProvider); diff --git a/packages/gitbook-core/src/components/Icon.js b/packages/gitbook-core/src/components/Icon.js new file mode 100644 index 0000000..5f2c751 --- /dev/null +++ b/packages/gitbook-core/src/components/Icon.js @@ -0,0 +1,28 @@ +const React = require('react'); + +const Icon = React.createClass({ + propTypes: { + id: React.PropTypes.string, + type: React.PropTypes.string, + className: React.PropTypes.string + }, + + getDefaultProps() { + return { + type: 'fa' + }; + }, + + render() { + const { id, type } = this.props; + let { className } = this.props; + + if (id) { + className = 'GitBook-Icon ' + type + ' ' + type + '-' + id; + } + + return <i className={className}/>; + } +}); + +module.exports = Icon; diff --git a/packages/gitbook-core/src/components/Import.js b/packages/gitbook-core/src/components/Import.js new file mode 100644 index 0000000..68318b9 --- /dev/null +++ b/packages/gitbook-core/src/components/Import.js @@ -0,0 +1,48 @@ +const React = require('react'); +const Head = require('react-helmet'); +const ReactRedux = require('react-redux'); + +/** + * Resolve a file url to a relative url in current state + * @param {String} href + * @param {State} state + * @return {String} + */ +function resolveForCurrentFile(href, state) { + const { file } = state; + return file.relative(href); +} + +const ImportLink = ReactRedux.connect((state, {rel, href}) => { + href = resolveForCurrentFile(href, state); + + return { + link: [ + { + rel, + href + } + ] + }; +})(Head); + +const ImportScript = ReactRedux.connect((state, {type, src}) => { + src = resolveForCurrentFile(src, state); + + return { + script: [ + { + type, + src + } + ] + }; +})(Head); + +const ImportCSS = props => <ImportLink rel="stylesheet" {...props} />; + +module.exports = { + ImportLink, + ImportScript, + ImportCSS +}; diff --git a/packages/gitbook-core/src/components/InjectedComponent.js b/packages/gitbook-core/src/components/InjectedComponent.js new file mode 100644 index 0000000..097edaf --- /dev/null +++ b/packages/gitbook-core/src/components/InjectedComponent.js @@ -0,0 +1,117 @@ +const React = require('react'); +const ReactRedux = require('react-redux'); +const { List } = require('immutable'); + +const { findMatchingComponents } = require('../actions/components'); + +/* + Public: InjectedComponent makes it easy to include a set of dynamically registered + components inside of your React render method. Rather than explicitly render + an array of buttons, for example, you can use InjectedComponentSet: + + ```js + <InjectedComponentSet className="message-actions" + matching={{role: 'ThreadActionButton'}} + props={{ a: 1 }}> + ``` + + InjectedComponentSet will look up components registered for the location you provide, + render them inside a {Flexbox} and pass them `exposedProps`. By default, all injected + children are rendered inside {UnsafeComponent} wrappers to prevent third-party code + from throwing exceptions that break React renders. + + InjectedComponentSet monitors the ComponentStore for changes. If a new component + is registered into the location you provide, InjectedComponentSet will re-render. + If no matching components is found, the InjectedComponent renders an empty span. + */ + +const Injection = React.createClass({ + propTypes: { + component: React.PropTypes.func, + props: React.PropTypes.object, + children: React.PropTypes.node + }, + + render() { + const Comp = this.props.component; + const { props, children } = this.props; + + // TODO: try to render with an error handling for unsafe component + return <Comp {...props}>{children}</Comp>; + } +}); + +const InjectedComponentSet = React.createClass({ + propTypes: { + components: React.PropTypes.oneOfType([ + React.PropTypes.arrayOf(React.PropTypes.func), + React.PropTypes.instanceOf(List) + ]).isRequired, + props: React.PropTypes.object, + children: React.PropTypes.node + }, + + render() { + const { components, props, children, ...divProps } = this.props; + + const inner = components.map((Comp, i) => <Injection key={i} component={Comp} props={props} />); + + return ( + <div {...divProps}> + {children} + {inner} + </div> + ); + } +}); + +/** + * Render only the first component matching + */ +const InjectedComponent = React.createClass({ + propTypes: { + components: React.PropTypes.oneOfType([ + React.PropTypes.arrayOf(React.PropTypes.func), + React.PropTypes.instanceOf(List) + ]).isRequired, + props: React.PropTypes.object, + children: React.PropTypes.node + }, + + render() { + let { components, props, children } = this.props; + + if (!children) { + children = null; + } else { + children = React.Children.only(children); + } + + return components.reduce((inner, Comp) => { + return ( + <Injection component={Comp} props={props}> + {inner} + </Injection> + ); + }, children); + } +}); + +/** + * Map Redux state to InjectedComponentSet's props + */ +function mapStateToProps(state, props) { + const { components } = state; + const { matching } = props; + + return { + components: findMatchingComponents(components, matching) + }; +} + +const connect = ReactRedux.connect(mapStateToProps); + +module.exports = { + InjectedComponent: connect(InjectedComponent), + InjectedComponentSet: connect(InjectedComponentSet) +}; diff --git a/packages/gitbook-core/src/components/Link.js b/packages/gitbook-core/src/components/Link.js new file mode 100644 index 0000000..ab364bb --- /dev/null +++ b/packages/gitbook-core/src/components/Link.js @@ -0,0 +1,37 @@ +const React = require('react'); +const ReactRedux = require('react-redux'); + +const File = require('../models/File'); +const SummaryArticle = require('../models/SummaryArticle'); +const SummaryArticleShape = require('../propTypes/SummaryArticle'); +const FileShape = require('../propTypes/File'); + +const Link = React.createClass({ + propTypes: { + currentFile: FileShape, + children: React.PropTypes.node, + + // Destination of the link + to: React.PropTypes.oneOfType([ + React.PropTypes.string, + SummaryArticleShape, + FileShape + ]) + }, + + render() { + const { currentFile, to, children, ...props } = this.props; + let href = to; + + if (SummaryArticle.is(to) || File.is(to)) { + href = to.url; + } + + href = currentFile.relative(href); + return <a href={href} {...props}>{children}</a>; + } +}); + +module.exports = ReactRedux.connect(state => { + return { currentFile: state.file }; +})(Link); diff --git a/packages/gitbook-core/src/components/PJAXWrapper.js b/packages/gitbook-core/src/components/PJAXWrapper.js new file mode 100644 index 0000000..6ed0697 --- /dev/null +++ b/packages/gitbook-core/src/components/PJAXWrapper.js @@ -0,0 +1,102 @@ +const React = require('react'); +const ReactRedux = require('react-redux'); +const History = require('../actions/history'); + +/** + * Check if an element is inside a link + * @param {DOMElement} el + * @param {String} name + * @return {DOMElement|undefined + */ +function findParentByTagName(el, name) { + while (el && el !== document) { + if (el.tagName && el.tagName.toUpperCase() === name) { + return el; + } + el = el.parentNode; + } + + return false; +} + +/** + * Internal: Return the `href` component of given URL object with the hash + * portion removed. + * + * @param {Location|HTMLAnchorElement} location + * @return {String} + */ +function stripHash(location) { + return location.href.replace(/#.*/, ''); +} + +/** + * Test if a click event should be handled, + * return the new url if it's a normal lcick + */ +function getHrefForEvent(event) { + const link = findParentByTagName(event.target, 'A'); + + if (!link) + return; + + // Middle click, cmd click, and ctrl click should open + // links in a new tab as normal. + if (event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) + return; + + // Ignore cross origin links + if (location.protocol !== link.protocol || location.hostname !== link.hostname) + return; + + // Ignore case when a hash is being tacked on the current URL + if (link.href.indexOf('#') > -1 && stripHash(link) == stripHash(location)) + return; + + // Ignore event with default prevented + if (event.defaultPrevented) + return; + + // Explicitly ignored + if (link.getAttribute('data-nopjax')) + return; + + return link.pathname; +} + +/* + Wrapper to bind all navigation events to fetch pages. + */ + +const PJAXWrapper = React.createClass({ + propTypes: { + children: React.PropTypes.node, + dispatch: React.PropTypes.func + }, + + onClick(event) { + const { dispatch } = this.props; + const href = getHrefForEvent(event); + + if (!href) { + return; + } + + event.preventDefault(); + dispatch(History.push(href)); + }, + + componentDidMount() { + document.addEventListener('click', this.onClick, false); + }, + + componentWillUnmount() { + document.removeEventListener('click', this.onClick, false); + }, + + render() { + return React.Children.only(this.props.children); + } +}); + +module.exports = ReactRedux.connect()(PJAXWrapper); diff --git a/packages/gitbook-core/src/components/Panel.js b/packages/gitbook-core/src/components/Panel.js new file mode 100644 index 0000000..694cc29 --- /dev/null +++ b/packages/gitbook-core/src/components/Panel.js @@ -0,0 +1,22 @@ +const React = require('react'); +const classNames = require('classnames'); + +const Panel = React.createClass({ + propTypes: { + className: React.PropTypes.string, + children: React.PropTypes.node + }, + + render() { + let { className, children } = this.props; + className = classNames('GitBook-Panel', className); + + return ( + <div className={className}> + {children} + </div> + ); + } +}); + +module.exports = Panel; diff --git a/packages/gitbook-core/src/components/Tooltipped.js b/packages/gitbook-core/src/components/Tooltipped.js new file mode 100644 index 0000000..4d297fd --- /dev/null +++ b/packages/gitbook-core/src/components/Tooltipped.js @@ -0,0 +1,44 @@ +const React = require('react'); +const classNames = require('classnames'); + +const POSITIONS = { + BOTTOM_RIGHT: 'e', + BOTTOM_LEFT: 'w', + TOP_LEFT: 'nw', + TOP_RIGHT: 'ne', + BOTTOM: '', + TOP: 'n' +}; + +const Tooltipped = React.createClass({ + propTypes: { + title: React.PropTypes.string.isRequired, + position: React.PropTypes.string, + open: React.PropTypes.bool, + children: React.PropTypes.node + }, + + statics: { + POSITIONS + }, + + render() { + const { title, position, open, children } = this.props; + + const className = classNames( + 'GitBook-Tooltipped', + position ? 'Tooltipped-' + position : '', + { + 'Tooltipped-o': open + } + ); + + return ( + <div className={className} aria-label={title}> + {children} + </div> + ); + } +}); + +module.exports = Tooltipped; diff --git a/packages/gitbook-core/src/index.js b/packages/gitbook-core/src/index.js new file mode 100644 index 0000000..3f0120c --- /dev/null +++ b/packages/gitbook-core/src/index.js @@ -0,0 +1,73 @@ +require('whatwg-fetch'); + +const React = require('react'); +const ReactCSSTransitionGroup = require('react-addons-css-transition-group'); +const Immutable = require('immutable'); +const Head = require('react-helmet'); +const Promise = require('bluebird'); +const { Provider } = require('react-redux'); +const { Flex, Box } = require('reflexbox'); + +const { InjectedComponent, InjectedComponentSet } = require('./components/InjectedComponent'); +const { ImportLink, ImportScript, ImportCSS } = require('./components/Import'); +const HTMLContent = require('./components/HTMLContent'); +const Link = require('./components/Link'); +const Icon = require('./components/Icon'); +const HotKeys = require('./components/HotKeys'); +const Button = require('./components/Button'); +const ButtonGroup = require('./components/ButtonGroup'); +const Dropdown = require('./components/Dropdown'); +const Panel = require('./components/Panel'); +const Backdrop = require('./components/Backdrop'); +const Tooltipped = require('./components/Tooltipped'); +const I18nProvider = require('./components/I18nProvider'); + +const ACTIONS = require('./actions/TYPES'); + +const PropTypes = require('./propTypes'); +const connect = require('./lib/connect'); +const createPlugin = require('./lib/createPlugin'); +const createReducer = require('./lib/createReducer'); +const createContext = require('./lib/createContext'); +const composeReducer = require('./lib/composeReducer'); +const bootstrap = require('./lib/bootstrap'); +const renderWithContext = require('./lib/renderWithContext'); + +module.exports = { + ACTIONS, + bootstrap, + renderWithContext, + connect, + createPlugin, + createReducer, + createContext, + composeReducer, + // React Components + I18nProvider, + InjectedComponent, + InjectedComponentSet, + HTMLContent, + Head, + Panel, + Provider, + ImportLink, + ImportScript, + ImportCSS, + FlexLayout: Flex, + FlexBox: Box, + Link, + Icon, + HotKeys, + Button, + ButtonGroup, + Dropdown, + Backdrop, + Tooltipped, + // Utilities + PropTypes, + // Librairies + React, + ReactCSSTransitionGroup, + Immutable, + Promise +}; diff --git a/packages/gitbook-core/src/lib/bootstrap.js b/packages/gitbook-core/src/lib/bootstrap.js new file mode 100644 index 0000000..f3c99b7 --- /dev/null +++ b/packages/gitbook-core/src/lib/bootstrap.js @@ -0,0 +1,29 @@ +const ReactDOM = require('react-dom'); + +const getPayload = require('./getPayload'); +const createContext = require('./createContext'); +const renderWithContext = require('./renderWithContext'); + +/** + * Bootstrap GitBook on the browser (this function should not be called on the server side). + * @param {Object} matching + */ +function bootstrap(matching) { + const initialState = getPayload(window.document); + const plugins = window.gitbookPlugins; + + const mountNode = document.getElementById('content'); + + // Create the redux store + const context = createContext(plugins, initialState); + + window.gitbookContext = context; + + // Render with the store + const el = renderWithContext(context, matching); + + ReactDOM.render(el, mountNode); +} + + +module.exports = bootstrap; diff --git a/packages/gitbook-core/src/lib/composeReducer.js b/packages/gitbook-core/src/lib/composeReducer.js new file mode 100644 index 0000000..fa2a589 --- /dev/null +++ b/packages/gitbook-core/src/lib/composeReducer.js @@ -0,0 +1,16 @@ + +/** + * Compose multiple reducers into one + * @param {Function} reducers + * @return {Function} + */ +function composeReducer(...reducers) { + return (state, action) => { + return reducers.reduce( + (newState, reducer) => reducer(newState, action), + state + ); + }; +} + +module.exports = composeReducer; diff --git a/packages/gitbook-core/src/lib/connect.js b/packages/gitbook-core/src/lib/connect.js new file mode 100644 index 0000000..a34299d --- /dev/null +++ b/packages/gitbook-core/src/lib/connect.js @@ -0,0 +1,70 @@ +const React = require('react'); +const ReactRedux = require('react-redux'); +const { injectIntl } = require('react-intl'); + +const ContextShape = require('../propTypes/Context'); + +/** + * Use the GitBook context provided by ContextProvider to map actions to props + * @param {ReactComponent} Component + * @param {Function} mapActionsToProps + * @return {ReactComponent} + */ +function connectToActions(Component, mapActionsToProps) { + if (!mapActionsToProps) { + return Component; + } + + return React.createClass({ + displayName: `ConnectActions(${Component.displayName})`, + propTypes: { + children: React.PropTypes.node + }, + + contextTypes: { + gitbook: ContextShape.isRequired + }, + + render() { + const { gitbook } = this.context; + const { children, ...props } = this.props; + const { actions, store } = gitbook; + + const actionsProps = mapActionsToProps(actions, store.dispatch); + + return <Component {...props} {...actionsProps}>{children}</Component>; + } + }); +} + +/** + * Connect to i18n + * @param {ReactComponent} Component + * @return {ReactComponent} + */ +function connectToI18n(Component) { + return injectIntl(({intl, children, ...props}) => { + const i18n = { + t: (id, values) => intl.formatMessage({ id }, values) + }; + + return <Component {...props} i18n={i18n}>{children}</Component>; + }); +} + +/** + * Connect a component to the GitBook context (store and actions). + * + * @param {ReactComponent} Component + * @param {Function} mapStateToProps + * @return {ReactComponent} + */ +function connect(Component, mapStateToProps, mapActionsToProps) { + Component = ReactRedux.connect(mapStateToProps)(Component); + Component = connectToI18n(Component); + Component = connectToActions(Component, mapActionsToProps); + + return Component; +} + +module.exports = connect; diff --git a/packages/gitbook-core/src/lib/createContext.js b/packages/gitbook-core/src/lib/createContext.js new file mode 100644 index 0000000..ba0c7e1 --- /dev/null +++ b/packages/gitbook-core/src/lib/createContext.js @@ -0,0 +1,76 @@ +/* eslint-disable no-console */ +const Redux = require('redux'); +const ReduxThunk = require('redux-thunk').default; + +const Plugin = require('../models/Plugin'); +const Context = require('../models/Context'); +const coreReducers = require('../reducers'); +const composeReducer = require('./composeReducer'); + +const Components = require('../actions/components'); +const I18n = require('../actions/i18n'); +const History = require('../actions/history'); + +const isBrowser = (typeof window !== 'undefined'); + +/** + * The core plugin defines the defualt behaviour of GitBook and provides + * actions to other plugins. + * @type {Plugin} + */ +const corePlugin = new Plugin({ + reduce: coreReducers, + actions: { + Components, I18n, History + } +}); + +/** + * Create a new context containing redux store from an initial state and a list of plugins. + * Each plugin entry is the result of {createPlugin}. + * + * @param {Array<Plugin>} plugins + * @param {Object} initialState + * @return {Context} context + */ +function createContext(plugins, initialState) { + plugins = [corePlugin].concat(plugins); + + // Compose the reducer from core with plugins + const pluginReducers = plugins.map(plugin => plugin.reduce); + const reducer = composeReducer(...pluginReducers); + + // Get actions from all plugins + const actions = plugins.reduce((accu, plugin) => { + return Object.assign(accu, plugin.actions); + }, {}); + + // Create thunk middleware which include actions + const thunk = ReduxThunk.withExtraArgument(actions); + + // Create the redux store + const store = Redux.createStore( + (state, action) => { + if (isBrowser) { + console.log('[store]', action.type); + } + return reducer(state, action); + }, + initialState, + Redux.compose(Redux.applyMiddleware(thunk)) + ); + + // Create the context + const context = new Context({ + store, + plugins, + actions + }); + + // Initialize the plugins + context.activate(); + + return context; +} + +module.exports = createContext; diff --git a/packages/gitbook-core/src/lib/createPlugin.js b/packages/gitbook-core/src/lib/createPlugin.js new file mode 100644 index 0000000..cb5d2be --- /dev/null +++ b/packages/gitbook-core/src/lib/createPlugin.js @@ -0,0 +1,27 @@ +const Plugin = require('../models/Plugin'); + +/** + * Create a plugin to extend the state and the views. + * + * @param {Function(dispatch, state)} plugin.init + * @param {Function(state, action)} plugin.reduce + * @param {Object} plugin.actions + * @return {Plugin} + */ +function createPlugin({ activate, deactivate, reduce, actions }) { + const plugin = new Plugin({ + activate, + deactivate, + reduce, + actions + }); + + if (typeof window !== 'undefined') { + window.gitbookPlugins = window.gitbookPlugins || []; + window.gitbookPlugins.push(plugin); + } + + return plugin; +} + +module.exports = createPlugin; diff --git a/packages/gitbook-core/src/lib/createReducer.js b/packages/gitbook-core/src/lib/createReducer.js new file mode 100644 index 0000000..2ebecfb --- /dev/null +++ b/packages/gitbook-core/src/lib/createReducer.js @@ -0,0 +1,27 @@ + +/** + * Helper to create a reducer that extend the store. + * + * @param {String} property + * @param {Function(state, action): state} reduce + * @return {Function(state, action): state} + */ +function createReducer(name, reduce) { + return (state, action) => { + const value = state[name]; + const newValue = reduce(value, action); + + if (newValue === value) { + return state; + } + + const newState = { + ...state, + [name]: newValue + }; + + return newState; + }; +} + +module.exports = createReducer; diff --git a/packages/gitbook-core/src/lib/getPayload.js b/packages/gitbook-core/src/lib/getPayload.js new file mode 100644 index 0000000..2d54b9e --- /dev/null +++ b/packages/gitbook-core/src/lib/getPayload.js @@ -0,0 +1,19 @@ + +/** + * Get the payload for a GitBook page + * @param {String|DOMDocument} html + * @return {Object} + */ +function getPayload(html) { + if (typeof html === 'string') { + const parser = new DOMParser(); + html = parser.parseFromString(html, 'text/html'); + } + + const script = html.querySelector('script[type="application/payload+json"]'); + const payload = JSON.parse(script.innerHTML); + + return payload; +} + +module.exports = getPayload; diff --git a/packages/gitbook-core/src/lib/renderWithContext.js b/packages/gitbook-core/src/lib/renderWithContext.js new file mode 100644 index 0000000..dc7e1f2 --- /dev/null +++ b/packages/gitbook-core/src/lib/renderWithContext.js @@ -0,0 +1,55 @@ +const React = require('react'); + +const { InjectedComponent } = require('../components/InjectedComponent'); +const PJAXWrapper = require('../components/PJAXWrapper'); +const I18nProvider = require('../components/I18nProvider'); +const ContextProvider = require('../components/ContextProvider'); +const History = require('../actions/history'); +const contextShape = require('../propTypes/context'); + +const GitBookApplication = React.createClass({ + propTypes: { + context: contextShape, + matching: React.PropTypes.object + }, + + componentDidMount() { + const { context } = this.props; + context.dispatch(History.activate()); + }, + + componentWillUnmount() { + const { context } = this.props; + context.dispatch(History.deactivate()); + }, + + render() { + const { context, matching } = this.props; + + return ( + <ContextProvider context={context}> + <PJAXWrapper> + <I18nProvider> + <InjectedComponent matching={matching} /> + </I18nProvider> + </PJAXWrapper> + </ContextProvider> + ); + } +}); + + +/** + * Render the application for a GitBook context. + * + * @param {GitBookContext} context + * @param {Object} matching + * @return {React.Element} element + */ +function renderWithContext(context, matching) { + return ( + <GitBookApplication context={context} matching={matching} /> + ); +} + +module.exports = renderWithContext; diff --git a/packages/gitbook-core/src/models/Context.js b/packages/gitbook-core/src/models/Context.js new file mode 100644 index 0000000..f4b0d4c --- /dev/null +++ b/packages/gitbook-core/src/models/Context.js @@ -0,0 +1,58 @@ +const { Record, List } = require('immutable'); + +const DEFAULTS = { + store: null, + actions: {}, + plugins: List() +}; + +class Context extends Record(DEFAULTS) { + constructor(...args) { + super(...args); + + this.dispatch = this.dispatch.bind(this); + this.getState = this.getState.bind(this); + } + + /** + * Return current state + * @return {Object} + */ + getState() { + const { store } = this; + return store.getState(); + } + + /** + * Dispatch an action + * @param {Action} action + */ + dispatch(action) { + const { store } = this; + return store.dispatch(action); + } + + /** + * Deactivate the context, cleanup resources from plugins. + */ + deactivate() { + const { plugins, actions } = this; + + plugins.forEach(plugin => { + plugin.deactivate(this.dispatch, this.getState, actions); + }); + } + + /** + * Activate the context and the plugins. + */ + activate() { + const { plugins, actions } = this; + + plugins.forEach(plugin => { + plugin.activate(this.dispatch, this.getState, actions); + }); + } +} + +module.exports = Context; diff --git a/packages/gitbook-core/src/models/File.js b/packages/gitbook-core/src/models/File.js new file mode 100644 index 0000000..efc4f11 --- /dev/null +++ b/packages/gitbook-core/src/models/File.js @@ -0,0 +1,54 @@ +const path = require('path'); +const { Record } = require('immutable'); + +const DEFAULTS = { + type: '', + mtime: new Date(), + path: '', + url: '' +}; + +class File extends Record(DEFAULTS) { + constructor(file = {}) { + if (typeof file === 'string') { + file = { path: file, url: file }; + } + + super({ + ...file, + mtime: new Date(file.mtime) + }); + } + + /** + * @param {String} to Absolute path + * @return {String} The same path, but relative to this file + */ + relative(to) { + return path.relative( + path.dirname(this.path), + to + ) || './'; + } + + /** + * Return true if file is an instance of File + * @param {Mixed} file + * @return {Boolean} isFile + */ + static is(file) { + return (file instanceof File); + } + + /** + * Create a file instance + * @param {Mixed|File} file + * @return {File} file + */ + static create(file) { + return File.is(file) ? + file : new File(file); + } +} + +module.exports = File; diff --git a/packages/gitbook-core/src/models/Language.js b/packages/gitbook-core/src/models/Language.js new file mode 100644 index 0000000..20fc237 --- /dev/null +++ b/packages/gitbook-core/src/models/Language.js @@ -0,0 +1,12 @@ +const { Record } = require('immutable'); + +const DEFAULTS = { + id: null, + title: null +}; + +class Language extends Record(DEFAULTS) { + +} + +module.exports = Language; diff --git a/packages/gitbook-core/src/models/Languages.js b/packages/gitbook-core/src/models/Languages.js new file mode 100644 index 0000000..b698d14 --- /dev/null +++ b/packages/gitbook-core/src/models/Languages.js @@ -0,0 +1,40 @@ +const { Record, List } = require('immutable'); +const Language = require('./Language'); +const File = require('./File'); + +const DEFAULTS = { + current: String(), + file: new File(), + list: List() +}; + +class Languages extends Record(DEFAULTS) { + constructor(spec = {}) { + super({ + ...spec, + file: File.create(spec.file), + list: List(spec.list).map(lang => new Language(lang)) + }); + } + + /** + * Return true if file is an instance of Languages + * @param {Mixed} langs + * @return {Boolean} + */ + static is(langs) { + return (langs instanceof Languages); + } + + /** + * Create a Languages instance + * @param {Mixed|Languages} langs + * @return {Languages} + */ + static create(langs) { + return Languages.is(langs) ? + langs : new Languages(langs); + } +} + +module.exports = Languages; diff --git a/packages/gitbook-core/src/models/Location.js b/packages/gitbook-core/src/models/Location.js new file mode 100644 index 0000000..cdfea2d --- /dev/null +++ b/packages/gitbook-core/src/models/Location.js @@ -0,0 +1,78 @@ +const { Record, Map } = require('immutable'); +const querystring = require('querystring'); + +const DEFAULTS = { + pathname: String(''), + // Hash without the # + hash: String(''), + // If query is a non empty map + query: Map() +}; + +class Location extends Record(DEFAULTS) { + + /** + * Return search query as a string + * @return {String} + */ + get search() { + const { query } = this; + return query.size === 0 ? + '' : + '?' + querystring.stringify(query.toJS()); + } + + /** + * Convert this location to a string. + * @return {String} + */ + toString() { + + } + + /** + * Convert this immutable instance to an object + * for "history". + * @return {Object} + */ + toNative() { + return { + pathname: this.pathname, + hash: this.hash ? `#${this.hash}` : '', + search: this.search + }; + } + + /** + * Convert an instance from "history" to Location. + * @param {Object|String} location + * @return {Location} + */ + static fromNative(location) { + if (typeof location === 'string') { + location = { pathname: location }; + } + + const pathname = location.pathname; + let hash = location.hash || ''; + let search = location.search || ''; + let query = location.query; + + hash = hash[0] === '#' ? hash.slice(1) : hash; + search = search[0] === '?' ? search.slice(1) : search; + + if (query) { + query = Map(query); + } else { + query = Map(querystring.parse(search)); + } + + return new Location({ + pathname, + hash, + query + }); + } +} + +module.exports = Location; diff --git a/packages/gitbook-core/src/models/Page.js b/packages/gitbook-core/src/models/Page.js new file mode 100644 index 0000000..e3c4a96 --- /dev/null +++ b/packages/gitbook-core/src/models/Page.js @@ -0,0 +1,24 @@ +const { Record, Map, fromJS } = require('immutable'); + +const DEFAULTS = { + title: '', + content: '', + dir: 'ltr', + depth: 1, + level: '', + previous: null, + next: null, + attributes: Map() +}; + +class Page extends Record(DEFAULTS) { + static create(state) { + return state instanceof Page ? + state : new Page({ + ...state, + attributes: fromJS(state.attributes) + }); + } +} + +module.exports = Page; diff --git a/packages/gitbook-core/src/models/Plugin.js b/packages/gitbook-core/src/models/Plugin.js new file mode 100644 index 0000000..07b1976 --- /dev/null +++ b/packages/gitbook-core/src/models/Plugin.js @@ -0,0 +1,21 @@ +const { Record } = require('immutable'); + +const DEFAULTS = { + activate: ((dispatch, getState) => {}), + deactivate: ((dispatch, getState) => {}), + reduce: ((state, action) => state), + actions: {} +}; + +class Plugin extends Record(DEFAULTS) { + constructor(plugin) { + super({ + activate: plugin.activate || DEFAULTS.activate, + deactivate: plugin.deactivate || DEFAULTS.deactivate, + reduce: plugin.reduce || DEFAULTS.reduce, + actions: plugin.actions || DEFAULTS.actions + }); + } +} + +module.exports = Plugin; diff --git a/packages/gitbook-core/src/models/Readme.js b/packages/gitbook-core/src/models/Readme.js new file mode 100644 index 0000000..f275ca2 --- /dev/null +++ b/packages/gitbook-core/src/models/Readme.js @@ -0,0 +1,21 @@ +const { Record } = require('immutable'); +const File = require('./File'); + +const DEFAULTS = { + file: new File() +}; + +class Readme extends Record(DEFAULTS) { + constructor(state = {}) { + super({ + file: File.create(state.file) + }); + } + + static create(state) { + return state instanceof Readme ? + state : new Readme(state); + } +} + +module.exports = Readme; diff --git a/packages/gitbook-core/src/models/SummaryArticle.js b/packages/gitbook-core/src/models/SummaryArticle.js new file mode 100644 index 0000000..3651c8a --- /dev/null +++ b/packages/gitbook-core/src/models/SummaryArticle.js @@ -0,0 +1,32 @@ +const { Record, List } = require('immutable'); + +const DEFAULTS = { + title: '', + depth: 0, + path: '', + url: '', + ref: '', + level: '', + articles: List() +}; + +class SummaryArticle extends Record(DEFAULTS) { + constructor(article) { + super({ + ...article, + articles: (new List(article.articles)) + .map(art => new SummaryArticle(art)) + }); + } + + /** + * Return true if article is an instance of SummaryArticle + * @param {Mixed} article + * @return {Boolean} + */ + static is(article) { + return (article instanceof SummaryArticle); + } +} + +module.exports = SummaryArticle; diff --git a/packages/gitbook-core/src/models/SummaryPart.js b/packages/gitbook-core/src/models/SummaryPart.js new file mode 100644 index 0000000..89c76d4 --- /dev/null +++ b/packages/gitbook-core/src/models/SummaryPart.js @@ -0,0 +1,17 @@ +const { Record, List } = require('immutable'); +const SummaryArticle = require('./SummaryArticle'); + +class SummaryPart extends Record({ + title: '', + articles: List() +}) { + constructor(state) { + super({ + ...state, + articles: (new List(state.articles)) + .map(article => new SummaryArticle(article)) + }); + } +} + +module.exports = SummaryPart; diff --git a/packages/gitbook-core/src/propTypes/Context.js b/packages/gitbook-core/src/propTypes/Context.js new file mode 100644 index 0000000..dd6d010 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/Context.js @@ -0,0 +1,11 @@ +const React = require('react'); +const { + object, + shape +} = React.PropTypes; + + +module.exports = shape({ + store: object, + actions: object +}); diff --git a/packages/gitbook-core/src/propTypes/File.js b/packages/gitbook-core/src/propTypes/File.js new file mode 100644 index 0000000..fb7bc06 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/File.js @@ -0,0 +1,13 @@ +const React = require('react'); +const { + oneOf, + string, + instanceOf, + shape +} = React.PropTypes; + +module.exports = shape({ + mtime: instanceOf(Date).isRequired, + path: string.isRequired, + type: oneOf(['', 'markdown', 'asciidoc']).isRequired +}); diff --git a/packages/gitbook-core/src/propTypes/History.js b/packages/gitbook-core/src/propTypes/History.js new file mode 100644 index 0000000..1b59ea0 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/History.js @@ -0,0 +1,11 @@ +const React = require('react'); +const locationShape = require('./Location'); +const { + bool, + shape +} = React.PropTypes; + +module.exports = shape({ + loading: bool, + location: locationShape +}); diff --git a/packages/gitbook-core/src/propTypes/Language.js b/packages/gitbook-core/src/propTypes/Language.js new file mode 100644 index 0000000..eea37a7 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/Language.js @@ -0,0 +1,7 @@ +const React = require('react'); +const { string, shape } = React.PropTypes; + +module.exports = shape({ + id: string.isRequired, + title: string.isRequired +}); diff --git a/packages/gitbook-core/src/propTypes/Languages.js b/packages/gitbook-core/src/propTypes/Languages.js new file mode 100644 index 0000000..076aec5 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/Languages.js @@ -0,0 +1,12 @@ +const React = require('react'); +const { listOf } = require('react-immutable-proptypes'); +const { shape, string } = React.PropTypes; + +const fileShape = require('./File'); +const languageShape = require('./Language'); + +module.exports = shape({ + current: string.isRequired, + file: fileShape.isRequired, + list: listOf(languageShape).isRequired +}); diff --git a/packages/gitbook-core/src/propTypes/Location.js b/packages/gitbook-core/src/propTypes/Location.js new file mode 100644 index 0000000..13e0a34 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/Location.js @@ -0,0 +1,12 @@ +const React = require('react'); +const { map } = require('react-immutable-proptypes'); +const { + string, + shape +} = React.PropTypes; + +module.exports = shape({ + pathname: string, + hash: string, + query: map +}); diff --git a/packages/gitbook-core/src/propTypes/Page.js b/packages/gitbook-core/src/propTypes/Page.js new file mode 100644 index 0000000..c589f54 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/Page.js @@ -0,0 +1,16 @@ +const React = require('react'); +const { + oneOf, + string, + number, + shape +} = React.PropTypes; + + +module.exports = shape({ + title: string.isRequired, + content: string.isRequired, + level: string.isRequired, + depth: number.isRequired, + dir: oneOf(['ltr', 'rtl']).isRequired +}); diff --git a/packages/gitbook-core/src/propTypes/Readme.js b/packages/gitbook-core/src/propTypes/Readme.js new file mode 100644 index 0000000..8414f05 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/Readme.js @@ -0,0 +1,11 @@ +const React = require('react'); + +const { + shape +} = React.PropTypes; + +const File = require('./File'); + +module.exports = shape({ + file: File.isRequired +}); diff --git a/packages/gitbook-core/src/propTypes/Summary.js b/packages/gitbook-core/src/propTypes/Summary.js new file mode 100644 index 0000000..f97e66c --- /dev/null +++ b/packages/gitbook-core/src/propTypes/Summary.js @@ -0,0 +1,14 @@ +const React = require('react'); +const { listOf } = require('react-immutable-proptypes'); + +const { + shape +} = React.PropTypes; + +const File = require('./File'); +const Part = require('./SummaryPart'); + +module.exports = shape({ + file: File.isRequired, + parts: listOf(Part).isRequired +}); diff --git a/packages/gitbook-core/src/propTypes/SummaryArticle.js b/packages/gitbook-core/src/propTypes/SummaryArticle.js new file mode 100644 index 0000000..c93bdd9 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/SummaryArticle.js @@ -0,0 +1,22 @@ +/* eslint-disable no-use-before-define */ + +const React = require('react'); +const { list } = require('react-immutable-proptypes'); + +const { + string, + number, + shape +} = React.PropTypes; + +const Article = shape({ + title: string.isRequired, + depth: number.isRequired, + path: string, + url: string, + ref: string, + level: string, + articles: list +}); + +module.exports = Article; diff --git a/packages/gitbook-core/src/propTypes/SummaryPart.js b/packages/gitbook-core/src/propTypes/SummaryPart.js new file mode 100644 index 0000000..769ddd1 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/SummaryPart.js @@ -0,0 +1,14 @@ +const React = require('react'); +const { listOf } = require('react-immutable-proptypes'); + +const { + string, + shape +} = React.PropTypes; + +const Article = require('./SummaryArticle'); + +module.exports = shape({ + title: string.isRequired, + articles: listOf(Article) +}); diff --git a/packages/gitbook-core/src/propTypes/i18n.js b/packages/gitbook-core/src/propTypes/i18n.js new file mode 100644 index 0000000..372a240 --- /dev/null +++ b/packages/gitbook-core/src/propTypes/i18n.js @@ -0,0 +1,10 @@ +const React = require('react'); +const { + func, + shape +} = React.PropTypes; + + +module.exports = shape({ + t: func +}); diff --git a/packages/gitbook-core/src/propTypes/index.js b/packages/gitbook-core/src/propTypes/index.js new file mode 100644 index 0000000..f56b78c --- /dev/null +++ b/packages/gitbook-core/src/propTypes/index.js @@ -0,0 +1,19 @@ +const React = require('react'); +const ImmutablePropTypes = require('react-immutable-proptypes'); + +module.exports = { + ...ImmutablePropTypes, + dispatch: React.PropTypes.func, + I18n: require('./i18n'), + Context: require('./Context'), + Page: require('./Page'), + File: require('./File'), + History: require('./History'), + Language: require('./Language'), + Languages: require('./Languages'), + Location: require('./Location'), + Readme: require('./Readme'), + Summary: require('./Summary'), + SummaryPart: require('./SummaryPart'), + SummaryArticle: require('./SummaryArticle') +}; diff --git a/packages/gitbook-core/src/reducers/components.js b/packages/gitbook-core/src/reducers/components.js new file mode 100644 index 0000000..948a3ac --- /dev/null +++ b/packages/gitbook-core/src/reducers/components.js @@ -0,0 +1,20 @@ +const { List } = require('immutable'); +const ACTION_TYPES = require('../actions/TYPES'); + +function reduceComponents(state, action) { + state = state || List(); + switch (action.type) { + + case ACTION_TYPES.REGISTER_COMPONENT: + return state.push({ + Component: action.Component, + descriptor: action.descriptor + }); + + default: + return state; + + } +} + +module.exports = reduceComponents; diff --git a/packages/gitbook-core/src/reducers/config.js b/packages/gitbook-core/src/reducers/config.js new file mode 100644 index 0000000..a49c602 --- /dev/null +++ b/packages/gitbook-core/src/reducers/config.js @@ -0,0 +1,15 @@ +const { fromJS } = require('immutable'); +const ACTION_TYPES = require('../actions/TYPES'); + +module.exports = (state, action) => { + state = fromJS(state); + switch (action.type) { + + case ACTION_TYPES.PAGE_FETCH_END: + return fromJS(action.payload.config); + + default: + return state; + + } +}; diff --git a/packages/gitbook-core/src/reducers/file.js b/packages/gitbook-core/src/reducers/file.js new file mode 100644 index 0000000..82b0f42 --- /dev/null +++ b/packages/gitbook-core/src/reducers/file.js @@ -0,0 +1,16 @@ +const ACTION_TYPES = require('../actions/TYPES'); +const File = require('../models/File'); + +module.exports = (state, action) => { + state = File.create(state); + + switch (action.type) { + + case ACTION_TYPES.PAGE_FETCH_END: + return state.merge(action.payload.file); + + default: + return state; + + } +}; diff --git a/packages/gitbook-core/src/reducers/history.js b/packages/gitbook-core/src/reducers/history.js new file mode 100644 index 0000000..be8fe42 --- /dev/null +++ b/packages/gitbook-core/src/reducers/history.js @@ -0,0 +1,82 @@ +const { Record, List } = require('immutable'); +const { createBrowserHistory, createMemoryHistory } = require('history'); +const ACTION_TYPES = require('../actions/TYPES'); +const Location = require('../models/Location'); + +const isServerSide = (typeof window === 'undefined'); + +const HistoryState = Record({ + // Current location + location: new Location(), + // Are we loading a new page + loading: Boolean(false), + // Did we fail loading a page? + error: null, + // Listener for history changes + listeners: List(), + // Function to call to stop listening + unlisten: null, + // HistoryJS instance + client: null +}); + +function reduceHistory(state, action) { + state = state || HistoryState(); + switch (action.type) { + + case ACTION_TYPES.PAGE_FETCH_START: + return state.merge({ + loading: true + }); + + case ACTION_TYPES.PAGE_FETCH_END: + return state.merge({ + loading: false + }); + + case ACTION_TYPES.PAGE_FETCH_ERROR: + return state.merge({ + loading: false, + error: action.error + }); + + case ACTION_TYPES.HISTORY_ACTIVATE: + const client = isServerSide ? createMemoryHistory() : createBrowserHistory(); + const unlisten = client.listen(action.listener); + + // We can't use .merge since it convert history to an immutable + const newState = state + // TODO: we should find a way to have the correct location on server side + .set('location', isServerSide ? new Location() : Location.fromNative(window.location)) + .set('client', client) + .set('unlisten', unlisten); + + return newState; + + case ACTION_TYPES.HISTORY_DEACTIVATE: + if (state.unlisten) { + state.unlisten(); + } + + return state.merge({ + client: null, + unlisten: null + }); + + case ACTION_TYPES.HISTORY_UPDATE: + return state.merge({ + location: action.location + }); + + case ACTION_TYPES.HISTORY_LISTEN: + return state.merge({ + listeners: state.listeners.push(action.listener) + }); + + default: + return state; + + } +} + +module.exports = reduceHistory; diff --git a/packages/gitbook-core/src/reducers/i18n.js b/packages/gitbook-core/src/reducers/i18n.js new file mode 100644 index 0000000..4ffd129 --- /dev/null +++ b/packages/gitbook-core/src/reducers/i18n.js @@ -0,0 +1,27 @@ +const { Record, Map } = require('immutable'); +const ACTION_TYPES = require('../actions/TYPES'); + +const I18nState = Record({ + locale: 'en', + // Map of locale -> Map<String:String> + messages: Map() +}); + +function reduceI18n(state, action) { + state = state || I18nState(); + switch (action.type) { + + case ACTION_TYPES.I18N_REGISTER_LOCALE: + return state.merge({ + messages: state.messages.set(action.locale, + state.messages.get(action.locale, Map()).merge(action.messages) + ) + }); + + default: + return state; + + } +} + +module.exports = reduceI18n; diff --git a/packages/gitbook-core/src/reducers/index.js b/packages/gitbook-core/src/reducers/index.js new file mode 100644 index 0000000..a211d3b --- /dev/null +++ b/packages/gitbook-core/src/reducers/index.js @@ -0,0 +1,15 @@ +const composeReducer = require('../lib/composeReducer'); +const createReducer = require('../lib/createReducer'); + +module.exports = composeReducer( + createReducer('components', require('./components')), + createReducer('history', require('./history')), + createReducer('i18n', require('./i18n')), + // GitBook JSON + createReducer('config', require('./config')), + createReducer('file', require('./file')), + createReducer('page', require('./page')), + createReducer('summary', require('./summary')), + createReducer('readme', require('./readme')), + createReducer('languages', require('./languages')) +); diff --git a/packages/gitbook-core/src/reducers/languages.js b/packages/gitbook-core/src/reducers/languages.js new file mode 100644 index 0000000..0ec2ae4 --- /dev/null +++ b/packages/gitbook-core/src/reducers/languages.js @@ -0,0 +1,12 @@ +const Languages = require('../models/Languages'); + +module.exports = (state, action) => { + state = Languages.create(state); + + switch (action.type) { + + default: + return state; + + } +}; diff --git a/packages/gitbook-core/src/reducers/page.js b/packages/gitbook-core/src/reducers/page.js new file mode 100644 index 0000000..9b94d1e --- /dev/null +++ b/packages/gitbook-core/src/reducers/page.js @@ -0,0 +1,16 @@ +const ACTION_TYPES = require('../actions/TYPES'); +const Page = require('../models/Page'); + +module.exports = (state, action) => { + state = Page.create(state); + + switch (action.type) { + + case ACTION_TYPES.PAGE_FETCH_END: + return state.merge(action.payload.page); + + default: + return state; + + } +}; diff --git a/packages/gitbook-core/src/reducers/readme.js b/packages/gitbook-core/src/reducers/readme.js new file mode 100644 index 0000000..9e8656a --- /dev/null +++ b/packages/gitbook-core/src/reducers/readme.js @@ -0,0 +1,5 @@ +const Readme = require('../models/Readme'); + +module.exports = (state, action) => { + return Readme.create(state); +}; diff --git a/packages/gitbook-core/src/reducers/summary.js b/packages/gitbook-core/src/reducers/summary.js new file mode 100644 index 0000000..60568ef --- /dev/null +++ b/packages/gitbook-core/src/reducers/summary.js @@ -0,0 +1,28 @@ +const { Record, List } = require('immutable'); + +const File = require('../models/File'); +const SummaryPart = require('../models/SummaryPart'); + + +class SummaryState extends Record({ + file: new File(), + parts: List() +}) { + constructor(state = {}) { + super({ + ...state, + file: new File(state.file), + parts: (new List(state.parts)) + .map(article => new SummaryPart(article)) + }); + } + + static create(state) { + return state instanceof SummaryState ? + state : new SummaryState(state); + } +} + +module.exports = (state, action) => { + return SummaryState.create(state); +}; diff --git a/packages/gitbook-core/src/server.js b/packages/gitbook-core/src/server.js new file mode 100644 index 0000000..0363aa0 --- /dev/null +++ b/packages/gitbook-core/src/server.js @@ -0,0 +1,2 @@ +const ReactDOMServer = require('react-dom/server'); +module.exports = ReactDOMServer; |