diff options
Diffstat (limited to 'packages')
478 files changed, 20833 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; diff --git a/packages/gitbook-plugin-copy-code/.gitignore b/packages/gitbook-plugin-copy-code/.gitignore new file mode 100644 index 0000000..ef47881 --- /dev/null +++ b/packages/gitbook-plugin-copy-code/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Deployed apps should consider commenting this line out: +# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git +node_modules + +# vim swapfile +*.swp + +# Plugin assets +_assets/plugin.js diff --git a/packages/gitbook-plugin-copy-code/.npmignore b/packages/gitbook-plugin-copy-code/.npmignore new file mode 100644 index 0000000..a0e53cf --- /dev/null +++ b/packages/gitbook-plugin-copy-code/.npmignore @@ -0,0 +1,2 @@ +# Publish assets on NPM +!_assets/plugin.js diff --git a/packages/gitbook-plugin-copy-code/_assets/website/button.css b/packages/gitbook-plugin-copy-code/_assets/website/button.css new file mode 100644 index 0000000..2fd034e --- /dev/null +++ b/packages/gitbook-plugin-copy-code/_assets/website/button.css @@ -0,0 +1,27 @@ +.CodeBlockWithCopy-Container { + position: relative; +} + +.CodeBlockWithCopy-Button { + position: absolute; + top: 5px; + right: 5px; + padding: 3px 6px; + margin: 0px; + text-transform: uppercase; + border-radius: 3px; + line-height: 1em; + font-size: 12px; + border: 1px solid rgba(0,0,0, 0.1); + color: rgba(0,0,0, 0.4); + cursor: pointer; + display: none; +} + +.CodeBlockWithCopy-Container:hover .CodeBlockWithCopy-Button { + display: block; +} + +.CodeBlockWithCopy-Button:hover { + border-color: rgba(0,0,0, 0.2); +} diff --git a/packages/gitbook-plugin-copy-code/index.js b/packages/gitbook-plugin-copy-code/index.js new file mode 100644 index 0000000..e542ae8 --- /dev/null +++ b/packages/gitbook-plugin-copy-code/index.js @@ -0,0 +1,10 @@ + +module.exports = { + blocks: { + + }, + + hooks: { + + } +}; diff --git a/packages/gitbook-plugin-copy-code/package.json b/packages/gitbook-plugin-copy-code/package.json new file mode 100644 index 0000000..b25ca43 --- /dev/null +++ b/packages/gitbook-plugin-copy-code/package.json @@ -0,0 +1,29 @@ +{ + "name": "gitbook-plugin-copy-code", + "description": "Button to copy code blocks", + "main": "index.js", + "browser": "./_assets/plugin.js", + "version": "4.0.0", + "dependencies": { + "copy-to-clipboard": "^3.0.5", + "gitbook-core": "4.0.0" + }, + "devDependencies": { + "gitbook-plugin": "4.0.0" + }, + "engines": { + "gitbook": ">=3.0.0" + }, + "scripts": { + "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js", + "prepublish": "npm run build-js" + }, + "homepage": "https://github.com/GitbookIO/gitbook", + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + } +} diff --git a/packages/gitbook-plugin-copy-code/src/index.js b/packages/gitbook-plugin-copy-code/src/index.js new file mode 100644 index 0000000..73d46c6 --- /dev/null +++ b/packages/gitbook-plugin-copy-code/src/index.js @@ -0,0 +1,82 @@ +const copy = require('copy-to-clipboard'); +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const COPIED_TIMEOUT = 1000; + +/** + * Get children as text + * @param {React.Children} children + * @return {String} + */ +function getChildrenToText(children) { + return React.Children.map(children, child => { + if (typeof child === 'string') { + return child; + } else { + return child.props.children ? + getChildrenToText(child.props.children) : ''; + } + }).join(''); +} + +let CodeBlockWithCopy = React.createClass({ + propTypes: { + children: React.PropTypes.node, + i18n: GitBook.PropTypes.I18n + }, + + getInitialState() { + return { + copied: false + }; + }, + + onClick(event) { + const { children } = this.props; + + event.preventDefault(); + event.stopPropagation(); + + const text = getChildrenToText(children); + copy(text); + + this.setState({ copied: true }, () => { + this.timeout = setTimeout(() => { + this.setState({ + copied: false + }); + }, COPIED_TIMEOUT); + }); + }, + + componentWillUnmount() { + if (this.timeout) { + clearTimeout(this.timeout); + } + }, + + render() { + const { children, i18n } = this.props; + const { copied } = this.state; + + return ( + <div className="CodeBlockWithCopy-Container"> + <GitBook.ImportCSS href="gitbook/copy-code/button.css" /> + + {children} + <span className="CodeBlockWithCopy-Button" onClick={this.onClick}> + {copied ? i18n.t('COPIED') : i18n.t('COPY')} + </span> + </div> + ); + } +}); + +CodeBlockWithCopy = GitBook.connect(CodeBlockWithCopy); + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { Components }) => { + dispatch(Components.registerComponent(CodeBlockWithCopy, { role: 'html:pre' })); + } +}); diff --git a/packages/gitbook-plugin-headings/.gitignore b/packages/gitbook-plugin-headings/.gitignore new file mode 100644 index 0000000..ef47881 --- /dev/null +++ b/packages/gitbook-plugin-headings/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Deployed apps should consider commenting this line out: +# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git +node_modules + +# vim swapfile +*.swp + +# Plugin assets +_assets/plugin.js diff --git a/packages/gitbook-plugin-headings/.npmignore b/packages/gitbook-plugin-headings/.npmignore new file mode 100644 index 0000000..a0e53cf --- /dev/null +++ b/packages/gitbook-plugin-headings/.npmignore @@ -0,0 +1,2 @@ +# Publish assets on NPM +!_assets/plugin.js diff --git a/packages/gitbook-plugin-headings/_assets/website/headings.css b/packages/gitbook-plugin-headings/_assets/website/headings.css new file mode 100644 index 0000000..1ef0d64 --- /dev/null +++ b/packages/gitbook-plugin-headings/_assets/website/headings.css @@ -0,0 +1,41 @@ + +.Headings-Container { + position: relative; + margin-left: -30px; + padding-left: 30px; +} + +/* Left anchors rules */ +.Headings-Container > .Headings-Anchor-Left { + position: absolute; + left: 5px; + top: 50%; + transform: translateY(-50%); + opacity: 0; + color: inherit; +} + +/* Right anchors rules */ +.Headings-Container > .Headings-Anchor-Right { + padding-left: 5px; + opacity: 0; + color: inherit; +} + +.Headings-Container.Headings-Right > h1, +.Headings-Container.Headings-Right > h2, +.Headings-Container.Headings-Right > h3, +.Headings-Container.Headings-Right > h4, +.Headings-Container.Headings-Right > h5, +.Headings-Container.Headings-Right > h6 { + display: inline-block; + margin-right: 5px; +} + +/* Display on hover */ +.Headings-Container:hover > .Headings-Anchor-Left, +.Headings-Container > .Headings-Anchor-Left:focus, +.Headings-Container:hover > .Headings-Anchor-Right, +.Headings-Container > .Headings-Anchor-Right:focus { + opacity: 1; +} diff --git a/packages/gitbook-plugin-headings/index.js b/packages/gitbook-plugin-headings/index.js new file mode 100644 index 0000000..e542ae8 --- /dev/null +++ b/packages/gitbook-plugin-headings/index.js @@ -0,0 +1,10 @@ + +module.exports = { + blocks: { + + }, + + hooks: { + + } +}; diff --git a/packages/gitbook-plugin-headings/package.json b/packages/gitbook-plugin-headings/package.json new file mode 100644 index 0000000..55a06f6 --- /dev/null +++ b/packages/gitbook-plugin-headings/package.json @@ -0,0 +1,38 @@ +{ + "name": "gitbook-plugin-headings", + "description": "Automatically add anchors to headings", + "main": "index.js", + "browser": "./_assets/plugin.js", + "version": "4.0.0", + "dependencies": { + "classnames": "^2.2.5", + "gitbook-core": "4.0.0" + }, + "devDependencies": { + "gitbook-plugin": "4.0.0" + }, + "engines": { + "gitbook": ">=3.0.0" + }, + "scripts": { + "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js", + "prepublish": "npm run build-js" + }, + "homepage": "https://github.com/GitbookIO/gitbook", + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + }, + "gitbook": { + "properties": { + "position": { + "type": "string", + "title": "Position of anchors", + "default": "left" + } + } + } +} diff --git a/packages/gitbook-plugin-headings/src/index.js b/packages/gitbook-plugin-headings/src/index.js new file mode 100644 index 0000000..b023e2e --- /dev/null +++ b/packages/gitbook-plugin-headings/src/index.js @@ -0,0 +1,60 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; +const classNames = require('classnames'); + +function mapStateToProps({ config }) { + return { + position: config.getIn(['pluginsConfig', 'headings', 'position'], 'left') + }; +} + +let Heading = React.createClass({ + propTypes: { + id: React.PropTypes.string.isRequired, + children: React.PropTypes.node.isRequired, + position: React.PropTypes.string.isRequired + }, + + render() { + const { position, children, id } = this.props; + const className = classNames('Headings-Container', { + 'Headings-Right': (position !== 'left') + }); + + return ( + <div className={className}> + <GitBook.ImportCSS href="gitbook/headings/headings.css" /> + + {position == 'left' ? + <a className="Headings-Anchor-Left" href={`#${id}`}> + <i className="fa fa-link" /> + </a> + : null} + + {children} + + {position != 'left' ? + <a className="Headings-Anchor-Right" href={`#${id}`}> + <i className="fa fa-link" /> + </a> + : null} + </div> + ); + } +}); + +Heading = GitBook.connect(Heading, mapStateToProps); + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { Components }) => { + // Attach component to titles + dispatch(Components.registerComponent(Heading, { role: 'html:h1' })); + dispatch(Components.registerComponent(Heading, { role: 'html:h2' })); + dispatch(Components.registerComponent(Heading, { role: 'html:h3' })); + dispatch(Components.registerComponent(Heading, { role: 'html:h4' })); + dispatch(Components.registerComponent(Heading, { role: 'html:h5' })); + dispatch(Components.registerComponent(Heading, { role: 'html:h6' })); + }, + deactivate: (dispatch, getState) => {}, + reduce: (state, action) => state +}); diff --git a/packages/gitbook-plugin-highlight/.gitignore b/packages/gitbook-plugin-highlight/.gitignore new file mode 100644 index 0000000..ef47881 --- /dev/null +++ b/packages/gitbook-plugin-highlight/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Deployed apps should consider commenting this line out: +# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git +node_modules + +# vim swapfile +*.swp + +# Plugin assets +_assets/plugin.js diff --git a/packages/gitbook-plugin-highlight/.npmignore b/packages/gitbook-plugin-highlight/.npmignore new file mode 100644 index 0000000..a0e53cf --- /dev/null +++ b/packages/gitbook-plugin-highlight/.npmignore @@ -0,0 +1,2 @@ +# Publish assets on NPM +!_assets/plugin.js diff --git a/packages/gitbook-plugin-highlight/_assets/website/white.css b/packages/gitbook-plugin-highlight/_assets/website/white.css new file mode 100644 index 0000000..d59f1d4 --- /dev/null +++ b/packages/gitbook-plugin-highlight/_assets/website/white.css @@ -0,0 +1,92 @@ +/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */ + +/* Tomorrow Comment */ +.hljs-comment, +.hljs-title { + color: #8e908c; +} + +/* Tomorrow Red */ +.hljs-variable, +.hljs-attribute, +.hljs-tag, +.hljs-regexp, +.hljs-deletion, +.ruby .hljs-constant, +.xml .hljs-tag .hljs-title, +.xml .hljs-pi, +.xml .hljs-doctype, +.html .hljs-doctype, +.css .hljs-id, +.css .hljs-class, +.css .hljs-pseudo { + color: #c82829; +} + +/* Tomorrow Orange */ +.hljs-number, +.hljs-preprocessor, +.hljs-pragma, +.hljs-built_in, +.hljs-literal, +.hljs-params, +.hljs-constant { + color: #f5871f; +} + +/* Tomorrow Yellow */ +.ruby .hljs-class .hljs-title, +.css .hljs-rules .hljs-attribute { + color: #eab700; +} + +/* Tomorrow Green */ +.hljs-string, +.hljs-value, +.hljs-inheritance, +.hljs-header, +.hljs-addition, +.ruby .hljs-symbol, +.xml .hljs-cdata { + color: #718c00; +} + +/* Tomorrow Aqua */ +.css .hljs-hexcolor { + color: #3e999f; +} + +/* Tomorrow Blue */ +.hljs-function, +.python .hljs-decorator, +.python .hljs-title, +.ruby .hljs-function .hljs-title, +.ruby .hljs-title .hljs-keyword, +.perl .hljs-sub, +.javascript .hljs-title, +.coffeescript .hljs-title { + color: #4271ae; +} + +/* Tomorrow Purple */ +.hljs-keyword, +.javascript .hljs-function { + color: #8959a8; +} + +.hljs { + display: block; + background: white; + color: #4d4d4c; + padding: 0.5em; +} + +.coffeescript .javascript, +.javascript .xml, +.tex .hljs-formula, +.xml .javascript, +.xml .vbscript, +.xml .css, +.xml .hljs-cdata { + opacity: 0.5; +} diff --git a/packages/gitbook-plugin-highlight/index.js b/packages/gitbook-plugin-highlight/index.js new file mode 100644 index 0000000..e542ae8 --- /dev/null +++ b/packages/gitbook-plugin-highlight/index.js @@ -0,0 +1,10 @@ + +module.exports = { + blocks: { + + }, + + hooks: { + + } +}; diff --git a/packages/gitbook-plugin-highlight/package.json b/packages/gitbook-plugin-highlight/package.json new file mode 100644 index 0000000..ce8b8d6 --- /dev/null +++ b/packages/gitbook-plugin-highlight/package.json @@ -0,0 +1,29 @@ +{ + "name": "gitbook-plugin-highlight", + "description": "Syntax highlighter for Gitbook", + "main": "index.js", + "browser": "./_assets/plugin.js", + "version": "4.0.0", + "dependencies": { + "gitbook-core": "4.0.0", + "highlight.js": "9.7.0" + }, + "devDependencies": { + "gitbook-plugin": "4.0.0" + }, + "engines": { + "gitbook": ">=3.0.0" + }, + "scripts": { + "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js", + "prepublish": "npm run build-js" + }, + "homepage": "https://github.com/GitbookIO/gitbook", + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + } +} diff --git a/packages/gitbook-plugin-highlight/src/ALIASES.js b/packages/gitbook-plugin-highlight/src/ALIASES.js new file mode 100644 index 0000000..799efef --- /dev/null +++ b/packages/gitbook-plugin-highlight/src/ALIASES.js @@ -0,0 +1,10 @@ + +const ALIASES = { + 'py': 'python', + 'js': 'javascript', + 'json': 'javascript', + 'rb': 'ruby', + 'csharp': 'cs' +}; + +module.exports = ALIASES; diff --git a/packages/gitbook-plugin-highlight/src/CodeBlock.js b/packages/gitbook-plugin-highlight/src/CodeBlock.js new file mode 100644 index 0000000..a556d36 --- /dev/null +++ b/packages/gitbook-plugin-highlight/src/CodeBlock.js @@ -0,0 +1,55 @@ +const hljs = require('highlight.js'); +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const getLanguage = require('./getLanguage'); + +/** + * Get children as text + * @param {React.Children} children + * @return {String} + */ +function getChildrenToText(children) { + return React.Children.map(children, child => { + if (typeof child === 'string') { + return child; + } else { + return child.props.children ? + getChildrenToText(child.props.children) : ''; + } + }).join(''); +} + +const CodeBlock = React.createClass({ + propTypes: { + children: React.PropTypes.node, + className: React.PropTypes.string + }, + + render() { + const { children, className } = this.props; + const content = getChildrenToText(children); + const lang = getLanguage(className || ''); + + const includeCSS = <GitBook.ImportCSS href="gitbook/highlight/white.css" />; + + try { + const html = hljs.highlight(lang, content).value; + return ( + <code> + {includeCSS} + <span dangerouslySetInnerHTML={{__html: html}} /> + </code> + ); + } catch (e) { + return ( + <code> + {includeCSS} + {content} + </code> + ); + } + } +}); + +module.exports = CodeBlock; diff --git a/packages/gitbook-plugin-highlight/src/getLanguage.js b/packages/gitbook-plugin-highlight/src/getLanguage.js new file mode 100644 index 0000000..7a1bf8e --- /dev/null +++ b/packages/gitbook-plugin-highlight/src/getLanguage.js @@ -0,0 +1,34 @@ +const GitBook = require('gitbook-core'); +const { List } = GitBook.Immutable; + +const ALIASES = require('./ALIASES'); + +/** + * Return language for a code blocks from a list of class names + * + * @param {String} className + * @return {String} + */ +function getLanguage(className) { + const lang = List(className.split(' ')) + .map(function(cl) { + // Markdown + if (cl.search('lang-') === 0) { + return cl.slice('lang-'.length); + } + + // Asciidoc + if (cl.search('language-') === 0) { + return cl.slice('language-'.length); + } + + return null; + }) + .find(function(cl) { + return Boolean(cl); + }); + + return ALIASES[lang] || lang; +} + +module.exports = getLanguage; diff --git a/packages/gitbook-plugin-highlight/src/index.js b/packages/gitbook-plugin-highlight/src/index.js new file mode 100644 index 0000000..3f17c42 --- /dev/null +++ b/packages/gitbook-plugin-highlight/src/index.js @@ -0,0 +1,9 @@ +const GitBook = require('gitbook-core'); +const CodeBlock = require('./CodeBlock'); + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { Components }) => { + dispatch(Components.registerComponent(CodeBlock, { role: 'html:code' })); + }, + reduce: (state, action) => state +}); diff --git a/packages/gitbook-plugin-hints/.gitignore b/packages/gitbook-plugin-hints/.gitignore new file mode 100644 index 0000000..ef47881 --- /dev/null +++ b/packages/gitbook-plugin-hints/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Deployed apps should consider commenting this line out: +# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git +node_modules + +# vim swapfile +*.swp + +# Plugin assets +_assets/plugin.js diff --git a/packages/gitbook-plugin-hints/.npmignore b/packages/gitbook-plugin-hints/.npmignore new file mode 100644 index 0000000..a0e53cf --- /dev/null +++ b/packages/gitbook-plugin-hints/.npmignore @@ -0,0 +1,2 @@ +# Publish assets on NPM +!_assets/plugin.js diff --git a/packages/gitbook-plugin-hints/README.md b/packages/gitbook-plugin-hints/README.md new file mode 100644 index 0000000..9952b97 --- /dev/null +++ b/packages/gitbook-plugin-hints/README.md @@ -0,0 +1,41 @@ +Styled hint blocks in your docs +============== + +This plugins requires gitbook `>=4.0.0`. + +### Install + +Add the below to your `book.json` file, then run `gitbook install` : + +```json +{ + "plugins": ["hints"] +} +``` + +### Usage + +You can now provide hints in various ways using the `hint` tag. + +```markdown +{% hint style='info' %} +Important info: this note needs to be highlighted +{% endhint %} +``` + +##### Styles + +Available styles are: + +- `info` (default) +- `tip` +- `danger` +- `warning` + +##### Custom Icons + +```markdown +{% hint style='info' icon="mail" %} +Important info: this note needs to be highlighted +{% endhint %} +``` diff --git a/packages/gitbook-plugin-hints/_assets/website/plugin.css b/packages/gitbook-plugin-hints/_assets/website/plugin.css new file mode 100644 index 0000000..343201b --- /dev/null +++ b/packages/gitbook-plugin-hints/_assets/website/plugin.css @@ -0,0 +1,43 @@ +.HintAlert { + padding: 10px; + border-radius: 3px; + display: flex; + margin-bottom: 1.275em; +} + +.HintAlert-Icon { + flex: 0; + padding: 10px 20px; + font-size: 24px; +} + +.HintAlert-Content { + flex: auto; + padding: 10px; + padding-left: 0px; +} + +/* Styles */ +.HintAlert-Style-info, .HintAlert-Style-tip { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} + +.HintAlert-Style-success { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} + +.HintAlert-Style-danger { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} + +.HintAlert-Style-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} diff --git a/packages/gitbook-plugin-hints/index.js b/packages/gitbook-plugin-hints/index.js new file mode 100644 index 0000000..c762232 --- /dev/null +++ b/packages/gitbook-plugin-hints/index.js @@ -0,0 +1,12 @@ + +module.exports = { + blocks: { + hint: ({ kwargs, children }) => { + return { + children, + style: kwargs.style || 'info', + icon: kwargs.icon + }; + } + } +}; diff --git a/packages/gitbook-plugin-hints/package.json b/packages/gitbook-plugin-hints/package.json new file mode 100644 index 0000000..3afad4d --- /dev/null +++ b/packages/gitbook-plugin-hints/package.json @@ -0,0 +1,29 @@ +{ + "name": "gitbook-plugin-hints", + "description": "Defines four types of styled hint blocks: info, danger, tip, working.", + "main": "index.js", + "browser": "./_assets/plugin.js", + "version": "4.0.0", + "dependencies": { + "classnames": "^2.2.5", + "gitbook-core": "4.0.0" + }, + "devDependencies": { + "gitbook-plugin": "4.0.0" + }, + "engines": { + "gitbook": ">=4.0.0" + }, + "scripts": { + "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js", + "prepublish": "npm run build-js" + }, + "homepage": "https://github.com/GitBookIO/gitbook", + "repository": { + "type": "git", + "url": "https://github.com/GitBookIO/gitbook.git" + }, + "bugs": { + "url": "https://github.com/GitBookIO/gitbook/issues" + } +} diff --git a/packages/gitbook-plugin-hints/src/index.js b/packages/gitbook-plugin-hints/src/index.js new file mode 100644 index 0000000..2ee8a1f --- /dev/null +++ b/packages/gitbook-plugin-hints/src/index.js @@ -0,0 +1,45 @@ +const classNames = require('classnames'); +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const STYLE_TO_ICON = { + info: 'info-circle', + tip: 'question', + success: 'check-circle', + danger: 'exclamation-circle', + warning: 'exclamation-triangle' +}; + +const HintAlert = React.createClass({ + propTypes: { + icon: React.PropTypes.string, + style: React.PropTypes.string, + children: React.PropTypes.node + }, + + render() { + const { children, style, icon } = this.props; + const className = classNames( + 'HintAlert', `HintAlert-Style-${style}`, + 'alert', `alert-${style}` + ); + + return ( + <div className={className}> + <GitBook.ImportCSS href="gitbook/hints/plugin.css" /> + <div className="HintAlert-Icon"> + <GitBook.Icon id={icon || STYLE_TO_ICON[style]} /> + </div> + <div className="HintAlert-Content"> + {children} + </div> + </div> + ); + } +}); + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { Components }) => { + dispatch(Components.registerComponent(HintAlert, { role: 'block:hint' })); + } +}); diff --git a/packages/gitbook-plugin-livereload/.gitignore b/packages/gitbook-plugin-livereload/.gitignore new file mode 100644 index 0000000..ef47881 --- /dev/null +++ b/packages/gitbook-plugin-livereload/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Deployed apps should consider commenting this line out: +# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git +node_modules + +# vim swapfile +*.swp + +# Plugin assets +_assets/plugin.js diff --git a/packages/gitbook-plugin-livereload/LICENSE b/packages/gitbook-plugin-livereload/LICENSE new file mode 100644 index 0000000..ad410e1 --- /dev/null +++ b/packages/gitbook-plugin-livereload/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License.
\ No newline at end of file diff --git a/packages/gitbook-plugin-livereload/README.md b/packages/gitbook-plugin-livereload/README.md new file mode 100644 index 0000000..e2d6f83 --- /dev/null +++ b/packages/gitbook-plugin-livereload/README.md @@ -0,0 +1,3 @@ +# `gitbook-plugin-livereload` + +See [GitBook](https://github.com/GitbookIO/gitbook) for more information. diff --git a/packages/gitbook-plugin-livereload/package.json b/packages/gitbook-plugin-livereload/package.json new file mode 100644 index 0000000..97df231 --- /dev/null +++ b/packages/gitbook-plugin-livereload/package.json @@ -0,0 +1,29 @@ +{ + "name": "gitbook-plugin-livereload", + "description": "Live reloading for your gitbook", + "main": "index.js", + "browser": "./_assets/plugin.js", + "version": "4.0.0", + "engines": { + "gitbook": "*" + }, + "dependencies": { + "gitbook-core": "4.0.0" + }, + "devDependencies": { + "gitbook-plugin": "4.0.0" + }, + "scripts": { + "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js", + "prepublish": "npm run build-js" + }, + "homepage": "https://github.com/GitbookIO/gitbook", + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "license": "Apache 2", + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + } +} diff --git a/packages/gitbook-plugin-livereload/src/index.js b/packages/gitbook-plugin-livereload/src/index.js new file mode 100644 index 0000000..e73f12d --- /dev/null +++ b/packages/gitbook-plugin-livereload/src/index.js @@ -0,0 +1,18 @@ +const GitBook = require('gitbook-core'); + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { Components }) => { + if (typeof window === 'undefined') { + return; + } + + const newEl = document.createElement('script'); + const firstScriptTag = document.getElementsByTagName('script')[0]; + + if (firstScriptTag) { + newEl.async = 1; + newEl.src = '//' + window.location.hostname + ':35729/livereload.js'; + firstScriptTag.parentNode.insertBefore(newEl, firstScriptTag); + } + } +}); diff --git a/packages/gitbook-plugin-lunr/.gitignore b/packages/gitbook-plugin-lunr/.gitignore new file mode 100644 index 0000000..7c6f0eb --- /dev/null +++ b/packages/gitbook-plugin-lunr/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Deployed apps should consider commenting this line out: +# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git +node_modules + +# vim swapfile +*.swp + +# Plugin assets +_assets diff --git a/packages/gitbook-plugin-lunr/.npmignore b/packages/gitbook-plugin-lunr/.npmignore new file mode 100644 index 0000000..7bc36b7 --- /dev/null +++ b/packages/gitbook-plugin-lunr/.npmignore @@ -0,0 +1,2 @@ +# Publish assets on NPM +!_assets diff --git a/packages/gitbook-plugin-lunr/index.js b/packages/gitbook-plugin-lunr/index.js new file mode 100644 index 0000000..bdde8f6 --- /dev/null +++ b/packages/gitbook-plugin-lunr/index.js @@ -0,0 +1,99 @@ +/* eslint-disable no-var, object-shorthand */ +var lunr = require('lunr'); +var Entities = require('html-entities').AllHtmlEntities; + +var Html = new Entities(); + +var searchIndex; + +// Called with the `this` context provided by Gitbook +function getSearchIndex(context) { + if (!searchIndex) { + // Create search index + var ignoreSpecialCharacters = ( + context.config.get('pluginsConfig.lunr.ignoreSpecialCharacters') + || context.config.get('lunr.ignoreSpecialCharacters') + ); + + searchIndex = lunr(function() { + this.ref('url'); + + this.field('title', { boost: 10 }); + this.field('keywords', { boost: 15 }); + this.field('body'); + + if (!ignoreSpecialCharacters) { + // Don't trim non words characters (to allow search such as "C++") + this.pipeline.remove(lunr.trimmer); + } + }); + } + return searchIndex; +} + +// Map of Lunr ref to document +var documentsStore = {}; + +var searchIndexEnabled = true; +var indexSize = 0; + +module.exports = { + hooks: { + // Index each page + 'page': function(page) { + const search = page.attributes.search; + + if (this.output.name != 'website' || !searchIndexEnabled || search === false) { + return page; + } + + var text, maxIndexSize; + maxIndexSize = this.config.get('pluginsConfig.lunr.maxIndexSize') || this.config.get('lunr.maxIndexSize'); + + this.log.debug.ln('index page', page.path); + + text = page.content; + // Decode HTML + text = Html.decode(text); + // Strip HTML tags + text = text.replace(/(<([^>]+)>)/ig, ''); + + indexSize = indexSize + text.length; + if (indexSize > maxIndexSize) { + this.log.warn.ln('search index is too big, indexing is now disabled'); + searchIndexEnabled = false; + return page; + } + + var keywords = []; + if (search) { + keywords = search.keywords || []; + } + + // Add to index + var doc = { + url: this.output.toURL(page.path), + title: page.title, + summary: page.description, + keywords: keywords.join(' '), + body: text + }; + + documentsStore[doc.url] = doc; + getSearchIndex(this).add(doc); + + return page; + }, + + // Write index to disk + 'finish': function() { + if (this.output.name != 'website') return; + + this.log.debug.ln('write search index'); + return this.output.writeFile('search_index.json', JSON.stringify({ + index: getSearchIndex(this), + store: documentsStore + })); + } + } +}; diff --git a/packages/gitbook-plugin-lunr/package.json b/packages/gitbook-plugin-lunr/package.json new file mode 100644 index 0000000..6a26f2d --- /dev/null +++ b/packages/gitbook-plugin-lunr/package.json @@ -0,0 +1,44 @@ +{ + "name": "gitbook-plugin-lunr", + "description": "Static and local index for search in GitBook", + "main": "index.js", + "browser": "./_assets/theme.js", + "version": "4.0.0", + "dependencies": { + "gitbook-core": "4.0.0", + "html-entities": "1.2.0", + "lunr": "0.5.12" + }, + "devDependencies": { + "gitbook-plugin": "4.0.0" + }, + "engines": { + "gitbook": ">=3.0.0" + }, + "gitbook": { + "properties": { + "maxIndexSize": { + "type": "number", + "title": "Limit size for the index", + "default": 1000000 + }, + "ignoreSpecialCharacters": { + "type": "boolean", + "title": "Ignore special characters in words", + "default": false + } + } + }, + "scripts": { + "build-js": "gitbook-plugin build ./src/index.js ./_assets/theme.js", + "prepublish": "npm run build-js" + }, + "homepage": "https://github.com/GitBookIO/gitbook", + "repository": { + "type": "git", + "url": "https://github.com/GitBookIO/gitbook.git" + }, + "bugs": { + "url": "https://github.com/GitBookIO/gitbook/issues" + } +} diff --git a/packages/gitbook-plugin-lunr/src/actions.js b/packages/gitbook-plugin-lunr/src/actions.js new file mode 100644 index 0000000..765fa2e --- /dev/null +++ b/packages/gitbook-plugin-lunr/src/actions.js @@ -0,0 +1,43 @@ +const GitBook = require('gitbook-core'); + +const TYPES = { + LOAD: 'lunr/load' +}; +const INDEX_FILENAME = 'search_index.json'; + +/** + * Load an index set + * @param {JSON} json + * @return {Action} + */ +function load(json) { + return { type: TYPES.LOAD, json }; +} + +/** + * Fetch an index + * @return {Action} + */ +function fetch() { + return (dispatch, getState) => { + const { lunr, file } = getState(); + const { idx } = lunr; + const filePath = file.relative(INDEX_FILENAME); + + if (idx) { + return GitBook.Promise.resolve(); + } + + return GitBook.Promise.resolve() + .then(() => { + return window.fetch(filePath); + }) + .then(response => response.json()) + .then(json => dispatch(load(json))); + }; +} + +module.exports = { + TYPES, + fetch +}; diff --git a/packages/gitbook-plugin-lunr/src/index.js b/packages/gitbook-plugin-lunr/src/index.js new file mode 100644 index 0000000..1135f51 --- /dev/null +++ b/packages/gitbook-plugin-lunr/src/index.js @@ -0,0 +1,28 @@ +const GitBook = require('gitbook-core'); +const reduce = require('./reducer'); +const actions = require('./actions'); + +/** + * Search in the local index + * @param {String} query + * @return {Promise<List>} + */ +function searchHandler(query, dispatch, getState) { + // Fetch the index if non loaded + return dispatch(actions.fetch()) + + // Execute the search + .then(() => { + const { idx, store } = getState().lunr; + const results = idx.search(query); + + return results.map(({ref}) => store.get(ref).toJS()); + }); +} + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { Search }) => { + dispatch(Search.registerHandler('lunr', searchHandler)); + }, + reduce +}); diff --git a/packages/gitbook-plugin-lunr/src/reducer.js b/packages/gitbook-plugin-lunr/src/reducer.js new file mode 100644 index 0000000..7e317c4 --- /dev/null +++ b/packages/gitbook-plugin-lunr/src/reducer.js @@ -0,0 +1,31 @@ +const lunr = require('lunr'); +const GitBook = require('gitbook-core'); +const { Record } = GitBook.Immutable; + +const { TYPES } = require('./actions'); + +/* + We store the lunr index an the document index in the store. + */ + +const LunrState = Record({ + idx: null, + store: {} +}); + +module.exports = GitBook.createReducer('lunr', (state, action) => { + state = state || LunrState(); + + switch (action.type) { + + case TYPES.LOAD: + return state + .set('idx', lunr.Index.load(action.json.index)) + .merge({ + store: action.json.store + }); + + default: + return state; + } +}); diff --git a/packages/gitbook-plugin-search/.gitignore b/packages/gitbook-plugin-search/.gitignore new file mode 100644 index 0000000..dfd90dc --- /dev/null +++ b/packages/gitbook-plugin-search/.gitignore @@ -0,0 +1 @@ +_assets diff --git a/packages/gitbook-plugin-search/.npmignore b/packages/gitbook-plugin-search/.npmignore new file mode 100644 index 0000000..75e0923 --- /dev/null +++ b/packages/gitbook-plugin-search/.npmignore @@ -0,0 +1 @@ +!_assets diff --git a/packages/gitbook-plugin-search/README.md b/packages/gitbook-plugin-search/README.md new file mode 100644 index 0000000..f667e4c --- /dev/null +++ b/packages/gitbook-plugin-search/README.md @@ -0,0 +1,41 @@ +# plugin-search + +This plugin is the interface used by all the search plugins (`plugin-lunr`, `plugin-algolia`, etc.) + +## Registering a Search handler + +Your plugin must register as a Search handler during its `activate` method: + + +``` js +GitBook.createPlugin({ + activate: (dispatch, getState, { Search }) => { + dispatch(Search.registerHandler('my-plugin-name', searchHandler)); + }, + reduce +}) + +/** + * Search against a query + * @param {String} query + * @return {Promise<List<Result>>} + */ +function searchHandler(query, dispatch, getState) { + ... +} +``` + +Your search handler must return a List of result-shaped objects. A result object has the following shape: + +``` js +result = { + title: string, // The title of the resource, as displayed in the list of results. + + url: string, // The URL to access the matched resource. + + body: string // (optional) The context of the matched text (can be a sentence + // containing matching words). It will be displayed near the result. +} +``` + + diff --git a/packages/gitbook-plugin-search/index.js b/packages/gitbook-plugin-search/index.js new file mode 100644 index 0000000..5803889 --- /dev/null +++ b/packages/gitbook-plugin-search/index.js @@ -0,0 +1,4 @@ + +module.exports = { + +}; diff --git a/packages/gitbook-plugin-search/package.json b/packages/gitbook-plugin-search/package.json new file mode 100644 index 0000000..ef4ae9e --- /dev/null +++ b/packages/gitbook-plugin-search/package.json @@ -0,0 +1,31 @@ +{ + "name": "gitbook-plugin-search", + "description": "Search integration in GitBook", + "main": "index.js", + "browser": "./_assets/theme.js", + "version": "4.0.0", + "dependencies": { + "gitbook-core": "4.0.0", + "react": "^15.4.1" + }, + "devDependencies": { + "gitbook-plugin": "4.0.0", + "react-highlighter": "^0.3.3" + }, + "engines": { + "gitbook": ">=3.0.0" + }, + "scripts": { + "build-js": "gitbook-plugin build ./src/index.js ./_assets/theme.js", + "prepublish": "npm run build-js" + }, + "homepage": "https://github.com/GitbookIO/gitbook", + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + } +} diff --git a/packages/gitbook-plugin-search/src/actions/search.js b/packages/gitbook-plugin-search/src/actions/search.js new file mode 100644 index 0000000..24151c6 --- /dev/null +++ b/packages/gitbook-plugin-search/src/actions/search.js @@ -0,0 +1,121 @@ +const { Promise, Immutable } = require('gitbook-core'); +const { List } = Immutable; + +const TYPES = require('./types'); +const Result = require('../models/Result'); + +/* + Search workflow: + + 1. Typing in the search input + 2. Trigger an update of the url + 3. An update of the url, trigger an update of search results + */ + +/** + * Start a search query + * @param {String} q + * @return {Action} + */ +function query(q) { + return (dispatch, getState, { History }) => { + const searchState = getState().search; + const currentQuery = searchState.query; + + const queryString = q ? { q } : {}; + + if (currentQuery && q) { + dispatch(History.replace({ query: queryString })); + } else { + dispatch(History.push({ query: queryString })); + } + }; +} + +/** + * Update results for a query + * @param {String} q + * @return {Action} + */ +function handleQuery(q) { + if (!q) { + return clear(); + } + + return (dispatch, getState, actions) => { + const { handlers } = getState().search; + + dispatch({ type: TYPES.START, query: q }); + + return Promise.reduce( + handlers.toArray(), + (results, handler) => { + return Promise.resolve(handler(q, dispatch, getState, actions)) + .then(handlerResults => { + return handlerResults.map(result => new Result(result)); + }) + .then(handlerResults => results.concat(handlerResults)); + }, + List() + ) + .then( + results => { + dispatch({ type: TYPES.END, query: q, results }); + } + ); + }; +} + +/** + * Refresh current search (when handlers have changed) + * @return {Action} + */ +function refresh() { + return (dispatch, getState) => { + const q = getState().search.query; + if (q) { + dispatch(handleQuery(q)); + } + }; +} + +/** + * Clear the whole search + * @return {Action} + */ +function clear() { + return { type: TYPES.CLEAR }; +} + +/** + * Register a search handler + * @param {String} name + * @param {Function} handler + * @return {Action} + */ +function registerHandler(name, handler) { + return (dispatch) => { + dispatch({ type: TYPES.REGISTER_HANDLER, name, handler }); + dispatch(refresh()); + }; +} + +/** + * Unregister a search handler + * @param {String} name + * @return {Action} + */ +function unregisterHandler(name) { + return (dispatch) => { + dispatch({ type: TYPES.UNREGISTER_HANDLER, name }); + dispatch(refresh()); + }; +} + +module.exports = { + clear, + query, + handleQuery, + registerHandler, + unregisterHandler +}; diff --git a/packages/gitbook-plugin-search/src/actions/types.js b/packages/gitbook-plugin-search/src/actions/types.js new file mode 100644 index 0000000..3cd1a89 --- /dev/null +++ b/packages/gitbook-plugin-search/src/actions/types.js @@ -0,0 +1,8 @@ + +module.exports = { + CLEAR: 'search/clear', + REGISTER_HANDLER: 'search/handlers/register', + UNREGISTER_HANDLER: 'search/handlers/unregister', + START: 'search/start', + END: 'search/end' +}; diff --git a/packages/gitbook-plugin-search/src/components/Input.js b/packages/gitbook-plugin-search/src/components/Input.js new file mode 100644 index 0000000..216a5d2 --- /dev/null +++ b/packages/gitbook-plugin-search/src/components/Input.js @@ -0,0 +1,73 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const search = require('../actions/search'); + +const ESCAPE = 27; + +const SearchInput = React.createClass({ + propTypes: { + query: React.PropTypes.string, + i18n: GitBook.PropTypes.I18n, + dispatch: GitBook.PropTypes.dispatch + }, + + onChange(event) { + const { dispatch } = this.props; + const { value } = event.currentTarget; + + dispatch(search.query(value)); + }, + + /** + * On Escape key down, clear the search field + */ + onKeyDown(e) { + const { query } = this.props; + if (e.keyCode == ESCAPE && query != '') { + e.preventDefault(); + e.stopPropagation(); + this.clearSearch(); + } + }, + + clearSearch() { + this.props.dispatch(search.query('')); + }, + + render() { + const { i18n, query } = this.props; + + let clear; + if (query != '') { + clear = ( + <span className="Search-Clear" + onClick={this.clearSearch}> + ✕ + </span> + ); + // clear = <GitBook.Icon id="x" onClick={this.clearSearch}/>; + } + + return ( + <div className="Search-Input"> + <input + type="text" + onKeyDown={this.onKeyDown} + value={query} + placeholder={i18n.t('SEARCH_PLACEHOLDER')} + onChange={this.onChange} + /> + + { clear } + </div> + ); + } +}); + +const mapStateToProps = state => { + const { query } = state.search; + return { query }; +}; + +module.exports = GitBook.connect(SearchInput, mapStateToProps); diff --git a/packages/gitbook-plugin-search/src/components/Results.js b/packages/gitbook-plugin-search/src/components/Results.js new file mode 100644 index 0000000..16a8cbd --- /dev/null +++ b/packages/gitbook-plugin-search/src/components/Results.js @@ -0,0 +1,80 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; +const Highlight = require('react-highlighter'); + +const MAX_DESCRIPTION_SIZE = 500; + +const Result = React.createClass({ + propTypes: { + result: React.PropTypes.object, + query: React.PropTypes.string + }, + + render() { + const { result, query } = this.props; + + let summary = result.body.trim(); + if (summary.length > MAX_DESCRIPTION_SIZE) { + summary = summary.slice(0, MAX_DESCRIPTION_SIZE).trim() + '...'; + } + + return ( + <div className="Search-ResultContainer"> + <GitBook.InjectedComponent matching={{ role: 'search:result' }} props={{ result, query }}> + <div className="Search-Result"> + <h3> + <GitBook.Link to={result.url}>{result.title}</GitBook.Link> + </h3> + <p> + <Highlight + matchElement="span" + matchClass="Search-MatchSpan" + search={query}> + {summary} + </Highlight> + </p> + </div> + </GitBook.InjectedComponent> + </div> + ); + } +}); + +const SearchResults = React.createClass({ + propTypes: { + i18n: GitBook.PropTypes.I18n, + results: GitBook.PropTypes.list, + query: React.PropTypes.string, + children: React.PropTypes.node + }, + + render() { + const { i18n, query, results, children } = this.props; + + if (!query) { + return React.Children.only(children); + } + + return ( + <div className="Search-ResultsContainer"> + <GitBook.InjectedComponent matching={{ role: 'search:results' }} props={{ results, query }}> + <div className="Search-Results"> + <h1>{i18n.t('SEARCH_RESULTS_TITLE', { query, count: results.size })}</h1> + <div className="Search-Results"> + {results.map((result, i) => { + return <Result key={i} result={result} query={query} />; + })} + </div> + </div> + </GitBook.InjectedComponent> + </div> + ); + } +}); + +const mapStateToProps = (state) => { + const { results, query } = state.search; + return { results, query }; +}; + +module.exports = GitBook.connect(SearchResults, mapStateToProps); diff --git a/packages/gitbook-plugin-search/src/index.js b/packages/gitbook-plugin-search/src/index.js new file mode 100644 index 0000000..f8c59aa --- /dev/null +++ b/packages/gitbook-plugin-search/src/index.js @@ -0,0 +1,33 @@ +const GitBook = require('gitbook-core'); + +const SearchInput = require('./components/Input'); +const SearchResults = require('./components/Results'); +const reducers = require('./reducers'); +const Search = require('./actions/search'); + +/** + * Url of the page changed, we update the search according to this. + * @param {GitBook.Location} location + * @param {Function} dispatch + */ +const onLocationChange = (location, dispatch) => { + const { query } = location; + const q = query.get('q'); + + dispatch(Search.handleQuery(q)); +}; + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { History, Components }) => { + // Register the navigation handler + dispatch(History.listen(onLocationChange)); + + // Register components + dispatch(Components.registerComponent(SearchInput, { role: 'search:container:input' })); + dispatch(Components.registerComponent(SearchResults, { role: 'search:container:results' })); + }, + reduce: reducers, + actions: { + Search + } +}); diff --git a/packages/gitbook-plugin-search/src/models/Result.js b/packages/gitbook-plugin-search/src/models/Result.js new file mode 100644 index 0000000..0012b2b --- /dev/null +++ b/packages/gitbook-plugin-search/src/models/Result.js @@ -0,0 +1,20 @@ +const GitBook = require('gitbook-core'); +const { Record } = GitBook.Immutable; + +const DEFAULTS = { + url: String(''), + title: String(''), + body: String('') +}; + +class Result extends Record(DEFAULTS) { + constructor(spec) { + if (!spec.url || !spec.title) { + throw new Error('"url" and "title" are required to create a search result'); + } + + super(spec); + } +} + +module.exports = Result; diff --git a/packages/gitbook-plugin-search/src/reducers/index.js b/packages/gitbook-plugin-search/src/reducers/index.js new file mode 100644 index 0000000..bfce2bd --- /dev/null +++ b/packages/gitbook-plugin-search/src/reducers/index.js @@ -0,0 +1,3 @@ +const GitBook = require('gitbook-core'); + +module.exports = GitBook.createReducer('search', require('./search')); diff --git a/packages/gitbook-plugin-search/src/reducers/search.js b/packages/gitbook-plugin-search/src/reducers/search.js new file mode 100644 index 0000000..b960a77 --- /dev/null +++ b/packages/gitbook-plugin-search/src/reducers/search.js @@ -0,0 +1,56 @@ +const GitBook = require('gitbook-core'); +const { Record, List, OrderedMap } = GitBook.Immutable; + +const TYPES = require('../actions/types'); + +const SearchState = Record({ + // Is the search being processed + loading: Boolean(false), + // Current query + query: String(''), + // Current list of results + results: List(), + // Search handlers + handlers: OrderedMap() +}); + +module.exports = (state = SearchState(), action) => { + switch (action.type) { + + case TYPES.CLEAR: + return state.merge({ + loading: false, + query: '', + results: List() + }); + + case TYPES.START: + return state.merge({ + loading: true, + query: action.query + }); + + case TYPES.END: + if (action.query !== state.query) { + return state; + } + + return state.merge({ + loading: false, + results: action.results + }); + + case TYPES.REGISTER_HANDLER: + return state.merge({ + handlers: state.handlers.set(action.name, action.handler) + }); + + case TYPES.UNREGISTER_HANDLER: + return state.merge({ + handlers: state.handlers.remove(action.name) + }); + + default: + return state; + } +}; diff --git a/packages/gitbook-plugin-sharing/.gitignore b/packages/gitbook-plugin-sharing/.gitignore new file mode 100644 index 0000000..ef47881 --- /dev/null +++ b/packages/gitbook-plugin-sharing/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Deployed apps should consider commenting this line out: +# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git +node_modules + +# vim swapfile +*.swp + +# Plugin assets +_assets/plugin.js diff --git a/packages/gitbook-plugin-sharing/.npmignore b/packages/gitbook-plugin-sharing/.npmignore new file mode 100644 index 0000000..a0e53cf --- /dev/null +++ b/packages/gitbook-plugin-sharing/.npmignore @@ -0,0 +1,2 @@ +# Publish assets on NPM +!_assets/plugin.js diff --git a/packages/gitbook-plugin-sharing/README.md b/packages/gitbook-plugin-sharing/README.md new file mode 100644 index 0000000..28ae0d4 --- /dev/null +++ b/packages/gitbook-plugin-sharing/README.md @@ -0,0 +1,38 @@ +# plugin-sharing + +This plugin adds sharing buttons in the GitBook website toolbar to share book on social networks. + +### Disable this plugin + +This is a default plugin and it can be disabled using a `book.json` configuration: + +``` +{ + plugins: ["-sharing"] +} +``` + +### Configuration + +This plugin can be configured in the `book.json`: + +Default configuration is: + +```js +{ + "pluginsConfig": { + "sharing": { + "facebook": true, + "twitter": true, + "google": false, + "weibo": false, + "instapaper": false, + "vk": false, + "all": [ + "facebook", "google", "twitter", + "weibo", "instapaper" + ] + } + } +} +``` diff --git a/packages/gitbook-plugin-sharing/index.js b/packages/gitbook-plugin-sharing/index.js new file mode 100644 index 0000000..e542ae8 --- /dev/null +++ b/packages/gitbook-plugin-sharing/index.js @@ -0,0 +1,10 @@ + +module.exports = { + blocks: { + + }, + + hooks: { + + } +}; diff --git a/packages/gitbook-plugin-sharing/package.json b/packages/gitbook-plugin-sharing/package.json new file mode 100644 index 0000000..b0540e8 --- /dev/null +++ b/packages/gitbook-plugin-sharing/package.json @@ -0,0 +1,77 @@ +{ + "name": "gitbook-plugin-sharing", + "description": "Sharing buttons in the toolbar", + "main": "index.js", + "browser": "./_assets/plugin.js", + "version": "4.0.0", + "gitbook": { + "properties": { + "facebook": { + "type": "boolean", + "default": true, + "title": "Facebook" + }, + "twitter": { + "type": "boolean", + "default": true, + "title": "Twitter" + }, + "google": { + "type": "boolean", + "default": false, + "title": "Google" + }, + "weibo": { + "type": "boolean", + "default": false, + "description": "Weibo" + }, + "instapaper": { + "type": "boolean", + "default": false, + "description": "Instapaper" + }, + "vk": { + "type": "boolean", + "default": false, + "description": "VK" + }, + "all": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "facebook", + "google", + "twitter", + "weibo", + "instapaper" + ], + "uniqueItems": true + } + } + }, + "dependencies": { + "gitbook-core": "4.0.0" + }, + "devDependencies": { + "gitbook-plugin": "4.0.0" + }, + "engines": { + "gitbook": ">=3.0.0" + }, + "scripts": { + "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js", + "prepublish": "npm run build-js" + }, + "homepage": "https://github.com/GitbookIO/gitbook", + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + }, + "license": "Apache-2.0" +} diff --git a/packages/gitbook-plugin-sharing/src/SITES.js b/packages/gitbook-plugin-sharing/src/SITES.js new file mode 100644 index 0000000..86eae74 --- /dev/null +++ b/packages/gitbook-plugin-sharing/src/SITES.js @@ -0,0 +1,72 @@ +// All the sharing platforms +const SITES = { + + // One sharing platform + 'facebook': { + // Displayed name + label: 'Facebook', + + // Font-awesome icon id + icon: 'facebook', + + /** + * Share a page on this platform + * @param {String} url The url to share + * @param {String} title The title of the url page + */ + onShare(url, title) { + url = encodeURIComponent(url); + window.open(`http://www.facebook.com/sharer/sharer.php?s=100&p[url]=${url}`); + } + }, + + 'twitter': { + label: 'Twitter', + icon: 'twitter', + onShare(url, title) { + const status = encodeURIComponent(title + ' ' + url); + window.open(`http://twitter.com/home?status=${status}`); + } + }, + + 'google': { + label: 'Google+', + icon: 'google-plus', + onShare(url, title) { + url = encodeURIComponent(url); + window.open(`https://plus.google.com/share?url=${url}`); + } + }, + + 'weibo': { + label: 'Weibo', + icon: 'weibo', + onShare(url, title) { + url = encodeURIComponent(url); + title = encodeURIComponent(title); + window.open(`http://service.weibo.com/share/share.php?content=utf-8&url=${url}&title=${title}`); + } + }, + + 'instapaper': { + label: 'Instapaper', + icon: 'instapaper', + onShare(url, title) { + url = encodeURIComponent(url); + window.open(`http://www.instapaper.com/text?u=${url}`); + } + }, + + 'vk': { + label: 'VK', + icon: 'vk', + onShare(url, title) { + url = encodeURIComponent(url); + window.open(`http://vkontakte.ru/share.php?url=${url}`); + } + } +}; + +SITES.ALL = Object.keys(SITES); + +module.exports = SITES; diff --git a/packages/gitbook-plugin-sharing/src/components/ShareButton.js b/packages/gitbook-plugin-sharing/src/components/ShareButton.js new file mode 100644 index 0000000..8983423 --- /dev/null +++ b/packages/gitbook-plugin-sharing/src/components/ShareButton.js @@ -0,0 +1,47 @@ +const GitBook = require('gitbook-core'); +const { React, Dropdown, Backdrop } = GitBook; + +const SITES = require('../SITES'); + +// Share button with dropdown list of sites +const ShareButton = React.createClass({ + propTypes: { + siteIds: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, + onShare: React.PropTypes.func.isRequired + }, + + getInitialState() { + return { open: false }; + }, + + onToggle() { + const { open } = this.state; + this.setState({ open: !open }); + }, + + render() { + const { siteIds, onShare } = this.props; + const { open } = this.state; + + return ( + <Dropdown.Container> + {open ? <Backdrop onClose={this.onToggle} /> : null} + + <GitBook.Button onClick={this.onToggle}> + <GitBook.Icon id="share-alt" /> + </GitBook.Button> + + {open ? ( + <Dropdown.Menu> + {siteIds.map((id) => ( + <Dropdown.ItemLink onClick={() => onShare(SITES[id])} key={id}> + {SITES[id].label} + </Dropdown.ItemLink> + ))} + </Dropdown.Menu>) : null} + </Dropdown.Container> + ); + } +}); + +module.exports = ShareButton; diff --git a/packages/gitbook-plugin-sharing/src/components/SharingButtons.js b/packages/gitbook-plugin-sharing/src/components/SharingButtons.js new file mode 100644 index 0000000..4f5ada9 --- /dev/null +++ b/packages/gitbook-plugin-sharing/src/components/SharingButtons.js @@ -0,0 +1,63 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const SITES = require('../SITES'); +const optionsShape = require('../shapes/options'); +const SiteButton = require('./SiteButton'); +const ShareButton = require('./ShareButton'); + +/** + * Displays the group of sharing buttons + */ +const SharingButtons = React.createClass({ + propTypes: { + options: optionsShape.isRequired, + page: GitBook.PropTypes.Page.isRequired + }, + + onShare(site) { + site.onShare(location.href, this.props.page.title); + }, + + render() { + const { options } = this.props; + + // Highlighted sites + const mainButtons = SITES + .ALL + .filter(id => options[id]) + .map(id => <SiteButton key={id} onShare={this.onShare} site={SITES[id]} />); + + // Other sites + let shareButton = undefined; + if (options.all.length > 0) { + shareButton = ( + <ShareButton siteIds={options.all} + onShare={this.onShare} /> + ); + } + + return ( + <GitBook.ButtonGroup> + { mainButtons } + { shareButton } + </GitBook.ButtonGroup> + ); + } +}); + +function mapStateToProps(state) { + let options = state.config.getIn(['pluginsConfig', 'sharing']); + if (options) { + options = options.toJS(); + } else { + options = { all: [] }; + } + + return { + page: state.page, + options + }; +} + +module.exports = GitBook.connect(SharingButtons, mapStateToProps); diff --git a/packages/gitbook-plugin-sharing/src/components/SiteButton.js b/packages/gitbook-plugin-sharing/src/components/SiteButton.js new file mode 100644 index 0000000..e03720d --- /dev/null +++ b/packages/gitbook-plugin-sharing/src/components/SiteButton.js @@ -0,0 +1,29 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const siteShape = require('../shapes/site'); + +// An individual site sharing button +const SiteButton = React.createClass({ + propTypes: { + site: siteShape.isRequired, + onShare: React.PropTypes.func.isRequired + }, + + onClick(e) { + e.preventDefault(); + this.props.onShare(this.props.site); + }, + + render() { + const { site } = this.props; + + return ( + <GitBook.Button onClick={this.onClick}> + <GitBook.Icon id={site.icon}/> + </GitBook.Button> + ); + } +}); + +module.exports = SiteButton; diff --git a/packages/gitbook-plugin-sharing/src/index.js b/packages/gitbook-plugin-sharing/src/index.js new file mode 100644 index 0000000..174adfc --- /dev/null +++ b/packages/gitbook-plugin-sharing/src/index.js @@ -0,0 +1,9 @@ +const GitBook = require('gitbook-core'); +const SharingButtons = require('./components/SharingButtons'); + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { Components }) => { + // Dispatch initialization actions + dispatch(Components.registerComponent(SharingButtons, { role: 'toolbar:buttons:right' })); + } +}); diff --git a/packages/gitbook-plugin-sharing/src/optionsShape.js b/packages/gitbook-plugin-sharing/src/optionsShape.js new file mode 100644 index 0000000..dd51016 --- /dev/null +++ b/packages/gitbook-plugin-sharing/src/optionsShape.js @@ -0,0 +1,20 @@ +const { + bool, + arrayOf, + oneOf, + shape +} = require('gitbook-core').React.PropTypes; + +const { ALL } = require('./SITES'); + +const optionsShape = shape({ + facebook: bool, + twitter: bool, + google: bool, + weibo: bool, + instapaper: bool, + vk: bool, + all: arrayOf(oneOf(ALL)).isRequired +}); + +module.exports = optionsShape; diff --git a/packages/gitbook-plugin-sharing/src/shapes/options.js b/packages/gitbook-plugin-sharing/src/shapes/options.js new file mode 100644 index 0000000..885feb6 --- /dev/null +++ b/packages/gitbook-plugin-sharing/src/shapes/options.js @@ -0,0 +1,19 @@ +const { + bool, + arrayOf, + oneOf, + shape +} = require('gitbook-core').React.PropTypes; +const { ALL } = require('../SITES'); + +const optionsShape = shape({ + facebook: bool, + twitter: bool, + google: bool, + weibo: bool, + instapaper: bool, + vk: bool, + all: arrayOf(oneOf(ALL)).isRequired +}); + +module.exports = optionsShape; diff --git a/packages/gitbook-plugin-sharing/src/shapes/site.js b/packages/gitbook-plugin-sharing/src/shapes/site.js new file mode 100644 index 0000000..2227429 --- /dev/null +++ b/packages/gitbook-plugin-sharing/src/shapes/site.js @@ -0,0 +1,13 @@ +const { + string, + func, + shape +} = require('gitbook-core').React.PropTypes; + +const siteShape = shape({ + label: string.isRequired, + icon: string.isRequired, + onShare: func.isRequired +}); + +module.exports = siteShape; diff --git a/packages/gitbook-plugin-theme-default/.gitignore b/packages/gitbook-plugin-theme-default/.gitignore new file mode 100644 index 0000000..dfd90dc --- /dev/null +++ b/packages/gitbook-plugin-theme-default/.gitignore @@ -0,0 +1 @@ +_assets diff --git a/packages/gitbook-plugin-theme-default/.npmignore b/packages/gitbook-plugin-theme-default/.npmignore new file mode 100644 index 0000000..75e0923 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/.npmignore @@ -0,0 +1 @@ +!_assets diff --git a/packages/gitbook-plugin-theme-default/index.js b/packages/gitbook-plugin-theme-default/index.js new file mode 100644 index 0000000..f4d6253 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/index.js @@ -0,0 +1,3 @@ +module.exports = { + +}; diff --git a/packages/gitbook-plugin-theme-default/less/Body.less b/packages/gitbook-plugin-theme-default/less/Body.less new file mode 100644 index 0000000..4bc33db --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Body.less @@ -0,0 +1,9 @@ +.Body-Flex { + .flex(1 0 auto); +} + +.Body { + overflow: auto; + width: 100%; + height: 100%; +} diff --git a/packages/gitbook-plugin-theme-default/less/Button.less b/packages/gitbook-plugin-theme-default/less/Button.less new file mode 100644 index 0000000..336d16e --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Button.less @@ -0,0 +1,22 @@ +.GitBook-Button { + border: 0; + background-color: transparent; + background: @button-background; + color: @button-color; + text-align: center; + line-height: @line-height-base; + outline: none; + padding: @button-padding; + + &:hover { + color: @button-hover-color; + } + + &:focus, &:hover { + outline: none; + } +} + +.GitBook-ButtonGroup { + display: inline-block; +} diff --git a/packages/gitbook-plugin-theme-default/less/Dropdown.less b/packages/gitbook-plugin-theme-default/less/Dropdown.less new file mode 100644 index 0000000..2c341e4 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Dropdown.less @@ -0,0 +1,56 @@ +.GitBook-Dropdown { + display: inline-block; + position: relative; +} + +.GitBook-DropdownMenu { + position: absolute; + top: 100%; + right: 0; + z-index: 300; + border: 1px solid @dropdown-border-color; + margin: 5px; + margin-top: 0px; + border-radius: 3px; + background: @dropdown-background; + + &:before { + content: " "; + width: 0; + height: 0; + border-left: @dropdown-arrow-width solid transparent; + border-right: @dropdown-arrow-width solid transparent; + border-bottom: @dropdown-arrow-width solid @dropdown-border-color; + position: absolute; + top: -@dropdown-arrow-width; + right: 10px; + } + + &:after { + content: " "; + width: 0; + height: 0; + border-left: (@dropdown-arrow-width - 1) solid transparent; + border-right: (@dropdown-arrow-width - 1) solid transparent; + border-bottom: (@dropdown-arrow-width - 1) solid @dropdown-background; + position: absolute; + top: -(@dropdown-arrow-width - 1); + right: 11px; + } +} + +.GitBook-DropdownItem { + padding: @dropdown-padding-v @dropdown-padding-h; +} + +.GitBook-DropdownItemLink { + width: 100%; + display: inline-block; + padding: @dropdown-padding-v @dropdown-padding-h; + text-align: center; + color: @dropdown-color; + + &:hover { + color: @dropdown-hover-color; + } +} diff --git a/packages/gitbook-plugin-theme-default/less/LoadingBar.less b/packages/gitbook-plugin-theme-default/less/LoadingBar.less new file mode 100644 index 0000000..1fca2ea --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/LoadingBar.less @@ -0,0 +1,30 @@ +.LoadingBar { + pointer-events: none; + transition: 400ms linear all; + + .LoadingBar-Bar { + background: @color-primary; + height: 2px; + + position: fixed; + top: 0; + left: 0; + z-index: 10000; + display: none; + width: 100%; + border-radius: 0 1px 1px 0; + transition: width 350ms; + } + + .LoadingBar-Shadow { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 70px; + height: 2px; + border-radius: 50%; + opacity: .45; + box-shadow: @color-primary 1px 0 6px 1px; + } +} diff --git a/packages/gitbook-plugin-theme-default/less/Page.less b/packages/gitbook-plugin-theme-default/less/Page.less new file mode 100644 index 0000000..6011533 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Page.less @@ -0,0 +1,16 @@ +.PageContainer { + position: relative; + outline: none; + width: 100%; + max-width: @page-width; + margin: 0px auto; + padding: 20px 15px 40px 15px; + font-size: @page-font-size; + .gitbook-markdown(@md-color: @page-color, @md-line-height: @page-line-height); + overflow: visible; + + .glossary-term { + cursor: help; + text-decoration: underline; + } +} diff --git a/packages/gitbook-plugin-theme-default/less/Panel.less b/packages/gitbook-plugin-theme-default/less/Panel.less new file mode 100644 index 0000000..507396c --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Panel.less @@ -0,0 +1,7 @@ +.GitBook-Panel { + border: 2px solid #f5f5f5; + padding: 10px; + background: #fafafa; + border-radius: 2px; + margin-top: 20px; +} diff --git a/packages/gitbook-plugin-theme-default/less/Search.less b/packages/gitbook-plugin-theme-default/less/Search.less new file mode 100644 index 0000000..faa871f --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Search.less @@ -0,0 +1,38 @@ +.Search-Input { + padding: 6px; + background: transparent; + transition: top 0.5s ease; + background: #fff; + border-bottom: 1px solid @sidebar-border-color; + border-top: 1px solid @sidebar-border-color; + margin-bottom: 10px; + + // Move top to hide top border + margin-top: -1px; + + input, input:focus, input:hover { + width: 90%; // 10% room for clear input X + background: transparent; + border: 1px solid transparent; + box-shadow: none; + outline: none; + line-height: 22px; + padding: 7px 7px; + color: inherit; + } +} + +.Search-Clear { + width: 10%; + display: inline-block; + text-align: center; + font-size: 14px; + line-height: 22px; + color: @search-clear-color; + cursor: pointer; +} + + +.Search-MatchSpan { + background: @search-highlight-color; +} diff --git a/packages/gitbook-plugin-theme-default/less/Sidebar.less b/packages/gitbook-plugin-theme-default/less/Sidebar.less new file mode 100644 index 0000000..1689b9f --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Sidebar.less @@ -0,0 +1,29 @@ +.Sidebar-Flex { + .flex(0 0 @sidebar-width); + + &.Layout-enter { + margin-left: -@sidebar-width; + + &.Layout-enter-active { + margin-left: 0; + transition: margin-left 250ms ease-in-out; + } + } + + &.Layout-leave { + margin-left: 0; + + &.Layout-leave-active { + margin-left: -@sidebar-width; + transition: margin-left 250ms ease-in-out; + } + } +} + +.Sidebar { + height: 100%; + background: @sidebar-background; + background: rgb(250, 250, 250); + border-right: 1px solid @sidebar-border-color; + overflow-y: auto; +} diff --git a/packages/gitbook-plugin-theme-default/less/Summary.less b/packages/gitbook-plugin-theme-default/less/Summary.less new file mode 100644 index 0000000..1e1e8ba --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Summary.less @@ -0,0 +1,51 @@ +.Summary { + +} + +.SummaryPart { + +} + +.SummaryPart-Title { + margin: 0px; + padding: 2*@summary-article-padding-v @summary-article-padding-h; + text-transform: uppercase; + color: @summary-header-color; + font-size: inherit; + font-weight: inherit; +} + +.SummaryArticles { + list-style: none; + margin: 0px; + padding: 0px; +} + +.SummaryArticle { + list-style: none; + + a, span { + display: block; + padding: @summary-article-padding-v @summary-article-padding-h; + border-bottom: none; + color: @summary-article-color; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + position: relative; + text-decoration: none; + outline: none; + } + + a:hover { + text-decoration: none; + color: @summary-article-hover-color; + } + + &.active, &.active:hover { + a { + color: @summary-article-active-color; + background: @summary-article-active-background; + } + } +} diff --git a/packages/gitbook-plugin-theme-default/less/Toolbar.less b/packages/gitbook-plugin-theme-default/less/Toolbar.less new file mode 100644 index 0000000..8c59d96 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Toolbar.less @@ -0,0 +1,27 @@ +.Toolbar { + .Toolbar-Title { + padding: 0px 20px; + margin: 0; + font-size: 20px; + font-weight: 200; + text-align: center; + line-height: 50px; + opacity: 0; + .transition(~"opacity ease .2s"); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: @button-hover-color; + + a, a:hover { + text-decoration: none; + color: inherit; + } + } + + &:hover { + .Toolbar-Title { + opacity: 1; + } + } +} diff --git a/packages/gitbook-plugin-theme-default/less/Tooltipped.less b/packages/gitbook-plugin-theme-default/less/Tooltipped.less new file mode 100644 index 0000000..126daab --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/Tooltipped.less @@ -0,0 +1,100 @@ +.GitBook-Tooltipped { + display: inline-block; + position: relative; + + &:hover, &.Tooltipped-o { + &:after { + line-height: 1em; + background: @tooltip-background; + border-radius: @tooltip-radius; + bottom: auto; + top: ~"calc(100% + 10px)"; + color: @tooltip-color; + content: attr(aria-label); + display: block; + left: 50%; + padding: 5px 5px; + position: absolute; + white-space: nowrap; + z-index: @zindex-tooltip; + font-size: 13px; + text-transform: none; + font-weight: @font-size-base; + pointer-events: none; + transform: translateX(-50%); + } + + &:before { + border: solid; + border-color: @tooltip-background transparent; + bottom:auto; + top: ~"calc(100% + 5px)"; + border-width: 0px 5px 5px 5px; + content: ""; + display: block; + left: 50%; + position: absolute; + z-index: @zindex-tooltip+1; + transform: translateX(-50%); + } + } + + .north() { + &:after { + top: auto; + bottom: ~"calc(100% + 10px)"; + transform: translateX(0%); + } + &:before { + top: auto; + border-width: 5px 5px 0px 5px; + bottom: ~"calc(100% + 5px)"; + transform: translateX(0%); + } + } + .west() { + &:after { + left: auto; + right: 5px; + transform: translateX(0%); + } + &:before { + left: auto; + right: 10px; + transform: translateX(0%); + } + } + .east() { + &:after { + right: auto; + left: 5px; + transform: translateX(0%); + } + &:before { + right: auto; + left: 10px; + transform: translateX(0%); + } + } + + &.Tooltipped-e { + .east() + } + + &.Tooltipped-n { + .north(); + } + + &.Tooltipped-ne { + .north(); + .east(); + } + + &.Tooltipped-nw { + .north(); + .west(); + } + &.Tooltipped-sw, &.Tooltipped-w { + .west(); + } +} diff --git a/packages/gitbook-plugin-theme-default/less/main.less b/packages/gitbook-plugin-theme-default/less/main.less new file mode 100644 index 0000000..d3c0dd5 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/main.less @@ -0,0 +1,50 @@ +@import "../node_modules/preboot/less/preboot.less"; +@import "../node_modules/gitbook-markdown-css/less/mixin.less"; +@import "../node_modules/font-awesome/less/font-awesome.less"; + +@import "mixins.less"; +@import "reset.less"; +@import "variables.less"; + +@import "Button.less"; +@import "Sidebar.less"; +@import "Summary.less"; +@import "Page.less"; +@import "Toolbar.less"; +@import "Search.less"; +@import "Body.less"; +@import "Dropdown.less"; +@import "LoadingBar.less"; +@import "Tooltipped.less"; +@import "Panel.less"; + +* { + .box-sizing(border-box); + -webkit-overflow-scrolling: touch; + -webkit-tap-highlight-color: transparent; + -webkit-text-size-adjust: none; + -webkit-touch-callout: none; + -webkit-font-smoothing: antialiased; +} + +a { + text-decoration: none; +} + +html, body { + margin: 0px; + height: 100%; +} + +html { + font-size: 62.5%; +} + +body { + text-rendering: optimizeLegibility; + font-smoothing: antialiased; + font-family: @font-family-base; + font-size: @font-size-base; + letter-spacing: .2px; + .text-adjust(100%); +} diff --git a/packages/gitbook-plugin-theme-default/less/mixins.less b/packages/gitbook-plugin-theme-default/less/mixins.less new file mode 100644 index 0000000..e4308b9 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/mixins.less @@ -0,0 +1,15 @@ +.text-adjust(@value) { + text-size-adjust: @value; + -ms-text-size-adjust: @value; + -webkit-text-size-adjust: @value; +} + +// The 'flex' shorthand +// - applies to: flex items +// <positive-number>, initial, auto, or none +.flex(@columns: initial) { + -webkit-flex: @columns; + -moz-flex: @columns; + -ms-flex: @columns; + flex: @columns; +} diff --git a/packages/gitbook-plugin-theme-default/less/reset.less b/packages/gitbook-plugin-theme-default/less/reset.less new file mode 100644 index 0000000..a9c6f52 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/reset.less @@ -0,0 +1,396 @@ +/*! normalize.css v2.1.0 | MIT License | git.io/normalize */ + +/* ========================================================================== + HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined in IE 8/9. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +nav, +section, +summary { + display: block; +} + +/** + * Correct `inline-block` display not defined in IE 8/9. + */ + +audio, +canvas, +video { + display: inline-block; +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +[hidden] { + display: none; +} + +/* ========================================================================== + Base + ========================================================================== */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS text size adjust after orientation change, without disabling + * user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + -ms-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* ========================================================================== + Links + ========================================================================== */ + +/** + * Address `outline` inconsistency between Chrome and other browsers. + */ + +a:focus { + outline: thin dotted; +} + +/** + * Improve readability when focused and also mouse hovered in all browsers. + */ + +a:active, +a:hover { + outline: 0; +} + +/* ========================================================================== + Typography + ========================================================================== */ + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari 5, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9, Safari 5, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari 5 and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Correct font family set oddly in Safari 5 and Chrome. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, serif; + font-size: 1em; +} + +/** + * Improve readability of pre-formatted text in all browsers. + */ + +pre { + white-space: pre-wrap; +} + +/** + * Set consistent quote types. + */ + +q { + quotes: "\201C" "\201D" "\2018" "\2019"; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* ========================================================================== + Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9. + */ + +img { + border: 0; +} + +/** + * Correct overflow displayed oddly in IE 9. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* ========================================================================== + Figures + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari 5. + */ + +figure { + margin: 0; +} + +/* ========================================================================== + Forms + ========================================================================== */ + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * 1. Correct font family not being inherited in all browsers. + * 2. Correct font size not being inherited in all browsers. + * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. + */ + +button, +input, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +button, +input { + line-height: normal; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. + * Correct `select` style inheritance in Firefox 4+ and Opera. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * 1. Address box sizing set to `content-box` in IE 8/9. + * 2. Remove excess padding in IE 8/9. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome + * (include `-moz` to future-proof). + */ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; /* 2 */ + box-sizing: content-box; +} + +/** + * Remove inner padding and search cancel button in Safari 5 and Chrome + * on OS X. + */ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * 1. Remove default vertical scrollbar in IE 8/9. + * 2. Improve readability and alignment in all browsers. + */ + +textarea { + overflow: auto; /* 1 */ + vertical-align: top; /* 2 */ +} + +/* ========================================================================== + Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/packages/gitbook-plugin-theme-default/less/variables.less b/packages/gitbook-plugin-theme-default/less/variables.less new file mode 100644 index 0000000..5c6842d --- /dev/null +++ b/packages/gitbook-plugin-theme-default/less/variables.less @@ -0,0 +1,55 @@ +// Colors +@color-primary: hsl(207, 100%, 50%); // rgb(44, 106, 254); +// Fonts +@font-family-serif: Georgia, serif; +@font-family-sans: "Helvetica Neue", Helvetica, Arial, sans-serif; +@font-family-base: @font-family-sans; +// Font sizes +@font-size-base: 14px; +@font-size-large: ceil(@font-size-base * 1.25); // ~18px +@font-size-small: ceil(@font-size-base * 0.85); // ~12px +@line-height-base: 1.428571429; // 20/14 +@line-height-computed: floor(@font-size-base * @line-height-base); +// Sidebar +@sidebar-background: rgb(250, 250, 250); +@sidebar-border-color: rgba(0, 0, 0, 0.0666667); +@sidebar-width: 300px; +// Summary +@summary-header-color: #939da3; +@summary-article-padding-v: 10px; +@summary-article-padding-h: 15px; +@summary-article-color: hsl(207, 15%, 25%); +@summary-article-hover-color: @color-primary; +@summary-article-active-color: @summary-article-color; +@summary-article-active-background: #f5f5f5; +// Page +@page-width: 800px; +@page-color: #333333; +@page-line-height: 1.7; +@page-font-size: 16px; +// Button +@button-padding: 19px; +@button-background: transparent; +@button-color: #bbb; +@button-hover-color: #a1a1a1; +// Dropdown +@dropdown-padding-v: 10px; +@dropdown-padding-h: 15px; +@dropdown-arrow-width: 8px; +@dropdown-border-color: #e5e5e5; +@dropdown-color: @button-color; +@dropdown-hover-color: @button-hover-color; +@dropdown-background: #fff; +// Tooltip +@tooltip-background: rgba(0,0,0,.8); +@tooltip-radius: 3px; +@tooltip-color: #fff; +// Search +@search-highlight-color: rgba(255, 220, 0, 0.4); +@search-clear-color: @button-color; +// Font awesome +@path-assets: '.'; +@path-fonts: '@{path-assets}/fonts'; +@fa-font-path: '@{path-fonts}/fontawesome'; +// Z-indexes +@zindex-tooltip: 300; diff --git a/packages/gitbook-plugin-theme-default/package.json b/packages/gitbook-plugin-theme-default/package.json new file mode 100644 index 0000000..7c44305 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/package.json @@ -0,0 +1,80 @@ +{ + "name": "gitbook-plugin-theme-default", + "description": "Default theme for GitBook", + "main": "./index.js", + "browser": "./_assets/theme.js", + "version": "4.0.0", + "engines": { + "gitbook": ">=3.0.0" + }, + "dependencies": { + "debounce": "^1.0.0", + "gitbook-core": "4.0.0" + }, + "devDependencies": { + "classnames": "^2.2.5", + "font-awesome": "^4.6.3", + "gitbook-markdown-css": "^1.0.1", + "gitbook-plugin": "4.0.0", + "less": "^2.7.1", + "less-plugin-clean-css": "^1.5.1", + "preboot": "git+https://github.com/mdo/preboot.git#4aab4edd85f076d50609cbe28e4fe66cc0771701" + }, + "scripts": { + "prepublish": "./prepublish.sh" + }, + "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" + }, + "contributors": [ + { + "name": "Samy Pessé", + "email": "samy@gitbook.com" + } + ], + "gitbook": { + "properties": { + "styles": { + "type": "object", + "title": "Custom Stylesheets", + "properties": { + "website": { + "title": "Stylesheet for website output", + "default": "styles/website.css" + }, + "pdf": { + "title": "Stylesheet for PDF output", + "default": "styles/pdf.css" + }, + "epub": { + "title": "Stylesheet for ePub output", + "default": "styles/epub.css" + }, + "mobi": { + "title": "Stylesheet for Mobi output", + "default": "styles/mobi.css" + }, + "ebook": { + "title": "Stylesheet for ebook outputs (PDF, ePub, Mobi)", + "default": "styles/ebook.css" + }, + "print": { + "title": "Stylesheet to replace default ebook css", + "default": "styles/print.css" + } + } + }, + "showLevel": { + "type": "boolean", + "title": "Show level indicator in TOC", + "default": false + } + } + } +} diff --git a/packages/gitbook-plugin-theme-default/prepublish.sh b/packages/gitbook-plugin-theme-default/prepublish.sh new file mode 100755 index 0000000..458df9b --- /dev/null +++ b/packages/gitbook-plugin-theme-default/prepublish.sh @@ -0,0 +1,11 @@ +#! /bin/bash +# +# Compile LESS To CSS +lessc -clean-css ./less/main.less ./_assets/website/theme.css + +# Compile JS +gitbook-plugin build ./src/index.js ./_assets/theme.js + +# Copy fonts +mkdir -p _assets/website/fonts +cp -R node_modules/font-awesome/fonts/ _assets/website/fonts/fontawesome/ diff --git a/packages/gitbook-plugin-theme-default/src/actions/sidebar.js b/packages/gitbook-plugin-theme-default/src/actions/sidebar.js new file mode 100644 index 0000000..52f8422 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/actions/sidebar.js @@ -0,0 +1,13 @@ +const ActionTypes = require('./types'); + +/** + * Toggle the sidebar + * @return {Action} + */ +function toggle() { + return { type: ActionTypes.TOGGLE_SIDEBAR }; +} + +module.exports = { + toggle +}; diff --git a/packages/gitbook-plugin-theme-default/src/actions/types.js b/packages/gitbook-plugin-theme-default/src/actions/types.js new file mode 100644 index 0000000..9f8a80f --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/actions/types.js @@ -0,0 +1,4 @@ + +module.exports = { + TOGGLE_SIDEBAR: 'theme-default/sidebar/toggle' +}; diff --git a/packages/gitbook-plugin-theme-default/src/components/Body.js b/packages/gitbook-plugin-theme-default/src/components/Body.js new file mode 100644 index 0000000..c61a2f3 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/components/Body.js @@ -0,0 +1,121 @@ +const debounce = require('debounce'); +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const Page = require('./Page'); +const Toolbar = require('./Toolbar'); + +const HEADINGS_SELECTOR = 'h1[id],h2[id],h3[id],h4[id]'; + +/** + * Get offset of an element relative to a parent container. + * @param {DOMElement} container + * @param {DOMElement} element + * @return {Number} offset + */ +function getOffset(container, element, type = 'Top') { + const parent = element.parentElement; + let base = 0; + + if (parent != container) { + base = getOffset(container, parent, type); + } + + return base + element[`offset${type}`]; +} + +/** + * Find the current heading anchor for a scroll position. + * @param {DOMElement} container + * @param {Number} top + * @return {String} + */ +function getHeadingID(container, top) { + let id; + const headings = container.querySelectorAll(HEADINGS_SELECTOR); + + headings.forEach(heading => { + if (id) { + return; + } + + const offset = getOffset(container, heading); + + if (offset > top) { + id = heading.getAttribute('id'); + } + }); + + return id; +} + +const Body = React.createClass({ + propTypes: { + page: GitBook.PropTypes.Page, + readme: GitBook.PropTypes.Readme, + history: GitBook.PropTypes.History, + updateURI: React.PropTypes.func + }, + + getInitialState() { + this.debouncedOnScroll = debounce(this.onScroll, 300); + return {}; + }, + + /** + * User is scrolling the page, update the location with current section's ID. + */ + onScroll() { + const { scrollContainer } = this; + const { history, updateURI } = this.props; + const { location } = history; + + // Find the id matching the current scroll position + const hash = getHeadingID(scrollContainer, scrollContainer.scrollTop); + + // Update url if changed + if (hash !== location.hash) { + updateURI(location.merge({ hash })); + } + }, + + /** + * Component has been updated with a new location, + * scroll to the right anchor. + */ + componentDidUpdate() { + + }, + + render() { + const { page, readme } = this.props; + + return ( + <GitBook.InjectedComponent matching={{ role: 'body:wrapper' }}> + <div + className="Body page-wrapper" + onScroll={this.debouncedOnScroll} + ref={div => this.scrollContainer = div} + > + <GitBook.InjectedComponent matching={{ role: 'toolbar:wrapper' }}> + <Toolbar title={page.title} readme={readme} /> + </GitBook.InjectedComponent> + <GitBook.InjectedComponent matching={{ role: 'page:wrapper' }}> + <Page page={page} /> + </GitBook.InjectedComponent> + </div> + </GitBook.InjectedComponent> + ); + } +}); + +module.exports = GitBook.connect(Body, + () => { + return {}; + }, + ({ History }, dispatch) => { + return { + updateURI: (location) => dispatch(History.replace(location)) + }; + } +); diff --git a/packages/gitbook-plugin-theme-default/src/components/LoadingBar.js b/packages/gitbook-plugin-theme-default/src/components/LoadingBar.js new file mode 100644 index 0000000..11e1ddb --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/components/LoadingBar.js @@ -0,0 +1,124 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +/** + * Displays a progress bar (YouTube-like) at the top of container + * Based on https://github.com/lonelyclick/react-loading-bar/blob/master/src/Loading.jsx + */ +const LoadingBar = React.createClass({ + propTypes: { + show: React.PropTypes.bool + }, + + getDefaultProps() { + return { + show: false + }; + }, + + getInitialState() { + return { + size: 0, + disappearDelayHide: false, // when dispappear, first transition then display none + percent: 0, + appearDelayWidth: 0 // when appear, first display block then transition width + }; + }, + + componentWillReceiveProps(nextProps) { + const { show } = nextProps; + + if (show) { + this.show(); + } else { + this.hide(); + } + }, + + shouldComponentUpdate(nextProps, nextState) { + return true; // !shallowEqual(nextState, this.state) + }, + + show() { + let { size, percent } = this.state; + + const appearDelayWidth = size === 0; + percent = calculatePercent(percent); + + this.setState({ + size: ++size, + appearDelayWidth, + percent + }); + + if (appearDelayWidth) { + setTimeout(() => { + this.setState({ + appearDelayWidth: false + }); + }); + } + }, + + hide() { + let { size } = this.state; + + if (--size < 0) { + this.setState({ size: 0 }); + return; + } + + this.setState({ + size: 0, + disappearDelayHide: true, + percent: 1 + }); + + setTimeout(() => { + this.setState({ + disappearDelayHide: false, + percent: 0 + }); + }, 500); + }, + + getBarStyle() { + const { disappearDelayHide, appearDelayWidth, percent } = this.state; + + return { + width: appearDelayWidth ? 0 : percent * 100 + '%', + display: disappearDelayHide || percent > 0 ? 'block' : 'none' + }; + }, + + getShadowStyle() { + const { percent, disappearDelayHide } = this.state; + + return { + display: disappearDelayHide || percent > 0 ? 'block' : 'none' + }; + }, + + render() { + return ( + <div className="LoadingBar"> + <div className="LoadingBar-Bar" style={this.getBarStyle()}> + <div className="LoadingBar-Shadow" + style={this.getShadowStyle()}> + </div> + </div> + </div> + ); + } +}); + +function calculatePercent(percent) { + percent = percent || 0; + + // How much of remaining bar we advance + const progress = 0.1 + Math.random() * 0.3; + + return percent + progress * (1 - percent); +} + +module.exports = LoadingBar; diff --git a/packages/gitbook-plugin-theme-default/src/components/Page.js b/packages/gitbook-plugin-theme-default/src/components/Page.js new file mode 100644 index 0000000..cbce704 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/components/Page.js @@ -0,0 +1,30 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const Page = React.createClass({ + propTypes: { + page: GitBook.PropTypes.Page + }, + + render() { + const { page } = this.props; + + return ( + <div className="PageContainer"> + <GitBook.InjectedComponent matching={{ role: 'search:container:results' }} props={this.props}> + <div className="Page"> + <GitBook.InjectedComponentSet matching={{ role: 'page:header' }} props={this.props} /> + + <GitBook.InjectedComponent matching={{ role: 'page:container' }} props={this.props}> + <GitBook.HTMLContent html={page.content} /> + </GitBook.InjectedComponent> + + <GitBook.InjectedComponentSet matching={{ role: 'page:footer' }} props={this.props} /> + </div> + </GitBook.InjectedComponent> + </div> + ); + } +}); + +module.exports = Page; diff --git a/packages/gitbook-plugin-theme-default/src/components/Sidebar.js b/packages/gitbook-plugin-theme-default/src/components/Sidebar.js new file mode 100644 index 0000000..ab628df --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/components/Sidebar.js @@ -0,0 +1,25 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const Summary = require('./Summary'); + +const Sidebar = React.createClass({ + propTypes: { + summary: GitBook.PropTypes.Summary + }, + + render() { + const { summary } = this.props; + + return ( + <div className="Sidebar-Flex"> + <div className="Sidebar book-summary"> + <GitBook.InjectedComponent matching={{ role: 'search:container:input' }} /> + <Summary summary={summary} /> + </div> + </div> + ); + } +}); + +module.exports = Sidebar; diff --git a/packages/gitbook-plugin-theme-default/src/components/Summary.js b/packages/gitbook-plugin-theme-default/src/components/Summary.js new file mode 100644 index 0000000..ef6ab3f --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/components/Summary.js @@ -0,0 +1,111 @@ +const classNames = require('classnames'); +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +let SummaryArticle = React.createClass({ + propTypes: { + active: React.PropTypes.bool, + article: GitBook.PropTypes.SummaryArticle + }, + + render() { + const { article, active } = this.props; + const className = classNames('SummaryArticle', { + active + }); + + return ( + <GitBook.InjectedComponent matching={{ role: 'summary:article' }} props={this.props}> + <li className={className}> + {article.ref ? + <GitBook.Link to={article}>{article.title}</GitBook.Link> + : <span>{article.title}</span>} + </li> + </GitBook.InjectedComponent> + ); + } +}); +SummaryArticle = GitBook.connect(SummaryArticle, ({page}, {article}) => { + return { + active: page.level === article.level + }; +}); + +const SummaryArticles = React.createClass({ + propTypes: { + articles: GitBook.PropTypes.listOf(GitBook.PropTypes.SummaryArticle) + }, + + render() { + const { articles } = this.props; + + return ( + <GitBook.InjectedComponent matching={{ role: 'summary:articles' }} props={this.props}> + <ul className="SummaryArticles"> + {articles.map(article => <SummaryArticle key={article.level} article={article} />)} + </ul> + </GitBook.InjectedComponent> + ); + } +}); + +const SummaryPart = React.createClass({ + propTypes: { + part: GitBook.PropTypes.SummaryPart + }, + + render() { + const { part } = this.props; + const { title, articles } = part; + + const titleEL = title ? <h2 className="SummaryPart-Title">{title}</h2> : null; + + return ( + <GitBook.InjectedComponent matching={{ role: 'summary:part' }} props={this.props}> + <div className="SummaryPart"> + {titleEL} + <SummaryArticles articles={articles} /> + </div> + </GitBook.InjectedComponent> + ); + } +}); + +const SummaryParts = React.createClass({ + propTypes: { + parts: GitBook.PropTypes.listOf(GitBook.PropTypes.SummaryPart) + }, + + render() { + const { parts } = this.props; + + return ( + <GitBook.InjectedComponent matching={{ role: 'summary:parts' }} props={this.props}> + <div className="SummaryParts"> + {parts.map((part, i) => <SummaryPart key={i} part={part} />)} + </div> + </GitBook.InjectedComponent> + ); + } +}); + +const Summary = React.createClass({ + propTypes: { + summary: GitBook.PropTypes.Summary + }, + + render() { + const { summary } = this.props; + const { parts } = summary; + + return ( + <GitBook.InjectedComponent matching={{ role: 'summary:container' }} props={this.props}> + <div className="Summary book-summary"> + <SummaryParts parts={parts} /> + </div> + </GitBook.InjectedComponent> + ); + } +}); + +module.exports = Summary; diff --git a/packages/gitbook-plugin-theme-default/src/components/Theme.js b/packages/gitbook-plugin-theme-default/src/components/Theme.js new file mode 100644 index 0000000..b323fc4 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/components/Theme.js @@ -0,0 +1,57 @@ +const GitBook = require('gitbook-core'); +const { React, ReactCSSTransitionGroup } = GitBook; + +const Sidebar = require('./Sidebar'); +const Body = require('./Body'); +const LoadingBar = require('./LoadingBar'); + +const Theme = React.createClass({ + propTypes: { + // State + page: GitBook.PropTypes.Page, + summary: GitBook.PropTypes.Summary, + readme: GitBook.PropTypes.Readme, + history: GitBook.PropTypes.History, + sidebar: React.PropTypes.object, + // Other props + children: React.PropTypes.node + }, + + render() { + const { page, summary, children, sidebar, readme, history } = this.props; + + return ( + <GitBook.FlexLayout column className="GitBook book"> + <LoadingBar show={history.loading} /> + <GitBook.Head + title={page.title} + titleTemplate="%s - GitBook" /> + <GitBook.ImportCSS href="gitbook/theme-default/theme.css" /> + + <GitBook.FlexBox> + <ReactCSSTransitionGroup + component={GitBook.FlexLayout} + transitionName="Layout" + transitionEnterTimeout={300} + transitionLeaveTimeout={300}> + {sidebar.open ? ( + <Sidebar key={0} summary={summary} /> + ) : null} + <div key={1} className="Body-Flex"> + <Body + page={page} + readme={readme} + history={history} + /> + </div> + </ReactCSSTransitionGroup> + </GitBook.FlexBox> + {children} + </GitBook.FlexLayout> + ); + } +}); + +module.exports = GitBook.connect(Theme, ({page, summary, sidebar, readme, history}) => { + return { page, summary, sidebar, readme, history }; +}); diff --git a/packages/gitbook-plugin-theme-default/src/components/Toolbar.js b/packages/gitbook-plugin-theme-default/src/components/Toolbar.js new file mode 100644 index 0000000..d426a40 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/components/Toolbar.js @@ -0,0 +1,43 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const sidebar = require('../actions/sidebar'); + +const Toolbar = React.createClass({ + propTypes: { + title: React.PropTypes.string.isRequired, + dispatch: React.PropTypes.func, + readme: GitBook.PropTypes.Readme + }, + + onToggle() { + const { dispatch } = this.props; + dispatch(sidebar.toggle()); + }, + + render() { + const { title, readme } = this.props; + + return ( + <GitBook.FlexLayout className="Toolbar"> + <GitBook.FlexBox className="Toolbar-left"> + <GitBook.InjectedComponentSet align="flex-end" matching={{ role: 'toolbar:buttons:left' }}> + <GitBook.Button onClick={this.onToggle}> + <GitBook.Icon id="align-justify" /> + </GitBook.Button> + </GitBook.InjectedComponentSet> + </GitBook.FlexBox> + <GitBook.FlexBox auto> + <h1 className="Toolbar-Title"> + <GitBook.Link to={readme.file}>{title}</GitBook.Link> + </h1> + </GitBook.FlexBox> + <GitBook.FlexBox className="Toolbar-right"> + <GitBook.InjectedComponentSet align="flex-end" matching={{ role: 'toolbar:buttons:right' }} /> + </GitBook.FlexBox> + </GitBook.FlexLayout> + ); + } +}); + +module.exports = GitBook.connect(Toolbar); diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ar.json b/packages/gitbook-plugin-theme-default/src/i18n/ar.json new file mode 100644 index 0000000..f652c1a --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/ar.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "اختيار اللغة", + "GLOSSARY": "قاموس مصطلحات", + "GLOSSARY_INDEX": "مؤشر المصطلحات", + "GLOSSARY_OPEN": "قاموس مصطلحات", + "GITBOOK_LINK": "نشرت مع GitBook", + "SUMMARY": "جدول المحتويات", + "SUMMARY_INTRODUCTION": "مقدمة", + "SUMMARY_TOGGLE": "جدول المحتويات", + "SEARCH_TOGGLE": "بحث", + "SEARCH_PLACEHOLDER": "اكتب للبحث", + "FONTSETTINGS_TOGGLE": "إعدادات الخط", + "SHARE_TOGGLE": "حصة", + "SHARE_ON": "على {{platform}} حصة", + "FONTSETTINGS_WHITE": "أبيض", + "FONTSETTINGS_SEPIA": "بني داكن", + "FONTSETTINGS_NIGHT": "ليل", + "FONTSETTINGS_SANS": "بلا", + "FONTSETTINGS_SERIF": "الرقيق" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/bn.json b/packages/gitbook-plugin-theme-default/src/i18n/bn.json new file mode 100644 index 0000000..24baec3 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/bn.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "ভাষা নির্বাচন করুন", + "GLOSSARY": "গ্লোসারি", + "GLOSSARY_INDEX": "ইন্ডেক্স", + "GLOSSARY_OPEN": "গ্লোসারি", + "GITBOOK_LINK": "গিটবুকের মাধ্যমে প্রকাশিত", + "SUMMARY": "সূচিপত্র", + "SUMMARY_INTRODUCTION": "সূচনা", + "SUMMARY_TOGGLE": "সূচিপত্র", + "SEARCH_TOGGLE": "অনুসন্ধান", + "SEARCH_PLACEHOLDER": "অনুসন্ধান", + "FONTSETTINGS_TOGGLE": "ফন্ট সেটিংস", + "SHARE_TOGGLE": "শেয়ার", + "SHARE_ON": "{{platform}}-এ শেয়ার", + "FONTSETTINGS_WHITE": "সাদা", + "FONTSETTINGS_SEPIA": "সেপিয়া", + "FONTSETTINGS_NIGHT": "রাত", + "FONTSETTINGS_SANS": "স্যান্স", + "FONTSETTINGS_SERIF": "শেরিফ" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ca.json b/packages/gitbook-plugin-theme-default/src/i18n/ca.json new file mode 100644 index 0000000..d26edb6 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/ca.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Selecciona un idioma", + "GLOSSARY": "Glossari", + "GLOSSARY_INDEX": "Índex", + "GLOSSARY_OPEN": "Glossari", + "GITBOOK_LINK": "Publicat amb GitBook", + "SUMMARY": "Taula de contingut", + "SUMMARY_INTRODUCTION": "Introducció", + "SUMMARY_TOGGLE": "Taula de contingut", + "SEARCH_TOGGLE": "Cercar", + "SEARCH_PLACEHOLDER": "Escriu per cercar", + "FONTSETTINGS_TOGGLE": "Configuració de font", + "SHARE_TOGGLE": "Compartir", + "SHARE_ON": "Compartir en {{platform}}", + "FONTSETTINGS_WHITE": "Clar", + "FONTSETTINGS_SEPIA": "Sèpia", + "FONTSETTINGS_NIGHT": "Nit", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/cs.json b/packages/gitbook-plugin-theme-default/src/i18n/cs.json new file mode 100644 index 0000000..b2e19c0 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/cs.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Zvolte jazyk", + "GLOSSARY": "Slovníček", + "GLOSSARY_INDEX": "Rejstřík", + "GLOSSARY_OPEN": "Slovníček", + "GITBOOK_LINK": "Publikováno pomocí GitBook", + "SUMMARY": "Obsah", + "SUMMARY_INTRODUCTION": "Úvod", + "SUMMARY_TOGGLE": "Obsah", + "SEARCH_TOGGLE": "Hledání", + "SEARCH_PLACEHOLDER": "Vyhledat", + "FONTSETTINGS_TOGGLE": "Nastavení písma", + "SHARE_TOGGLE": "Sdílet", + "SHARE_ON": "Sdílet na {{platform}}", + "FONTSETTINGS_WHITE": "Bílá", + "FONTSETTINGS_SEPIA": "Sépie", + "FONTSETTINGS_NIGHT": "Noc", + "FONTSETTINGS_SANS": "Bezpatkové", + "FONTSETTINGS_SERIF": "Patkové" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/de.json b/packages/gitbook-plugin-theme-default/src/i18n/de.json new file mode 100644 index 0000000..b51732e --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/de.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Sprache auswählen", + "GLOSSARY": "Glossar", + "GLOSSARY_INDEX": "Index", + "GLOSSARY_OPEN": "Glossar", + "GITBOOK_LINK": "Veröffentlicht mit GitBook", + "SUMMARY": "Inhaltsverzeichnis", + "SUMMARY_INTRODUCTION": "Einleitung", + "SUMMARY_TOGGLE": "Inhaltsverzeichnis", + "SEARCH_TOGGLE": "Suche", + "SEARCH_PLACEHOLDER": "Suchbegriff eingeben", + "FONTSETTINGS_TOGGLE": "Schrifteinstellungen", + "SHARE_TOGGLE": "Teilen", + "SHARE_ON": "Auf {{platform}} teilen", + "FONTSETTINGS_WHITE": "Hell", + "FONTSETTINGS_SEPIA": "Sepia", + "FONTSETTINGS_NIGHT": "Nacht", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +}
\ No newline at end of file diff --git a/packages/gitbook-plugin-theme-default/src/i18n/el.json b/packages/gitbook-plugin-theme-default/src/i18n/el.json new file mode 100644 index 0000000..5198e60 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/el.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Επιλογή γλώσσας", + "GLOSSARY": "Γλωσσάρι", + "GLOSSARY_INDEX": "Ευρετήριο", + "GLOSSARY_OPEN": "Γλωσσάρι", + "GITBOOK_LINK": "Δημοσιεύτηκε με το GitBook", + "SUMMARY": "Πίνακας Περιεχομένων", + "SUMMARY_INTRODUCTION": "Εισαγωγή", + "SUMMARY_TOGGLE": "Πίνακας Περιεχομένων", + "SEARCH_TOGGLE": "Αναζήτηση", + "SEARCH_PLACEHOLDER": "Αναζήτηση για ...", + "FONTSETTINGS_TOGGLE": "Επιλογές γραμματοσειράς", + "SHARE_TOGGLE": "Κοινοποίηση", + "SHARE_ON": "Κοινοποίηση σε {{platform}}", + "FONTSETTINGS_WHITE": "Λευκό", + "FONTSETTINGS_SEPIA": "Καστανόχρους", + "FONTSETTINGS_NIGHT": "Βραδινό", + "FONTSETTINGS_SANS": "Χωρίς πατούρες", + "FONTSETTINGS_SERIF": "Με πατούρες" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/en.json b/packages/gitbook-plugin-theme-default/src/i18n/en.json new file mode 100644 index 0000000..b6504d3 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/en.json @@ -0,0 +1,21 @@ +{ + "LANGS_CHOOSE": "Choose a language", + "GLOSSARY": "Glossary", + "GLOSSARY_INDEX": "Index", + "GLOSSARY_OPEN": "Glossary", + "GITBOOK_LINK": "Published with GitBook", + "SUMMARY": "Table of Contents", + "SUMMARY_INTRODUCTION": "Introduction", + "SUMMARY_TOGGLE": "Table of Contents", + "SEARCH_TOGGLE": "Search", + "SEARCH_PLACEHOLDER": "Type to search", + "SEARCH_RESULTS_TITLE": "{count, plural, =0 {No results} one {1 result} other {{count} results}} matching \"{query}\"", + "FONTSETTINGS_TOGGLE": "Font Settings", + "SHARE_TOGGLE": "Share", + "SHARE_ON": "Share on {{platform}}", + "FONTSETTINGS_WHITE": "White", + "FONTSETTINGS_SEPIA": "Sepia", + "FONTSETTINGS_NIGHT": "Night", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/es.json b/packages/gitbook-plugin-theme-default/src/i18n/es.json new file mode 100644 index 0000000..36159be --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/es.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Selecciona un idioma", + "GLOSSARY": "Glosario", + "GLOSSARY_INDEX": "Índice", + "GLOSSARY_OPEN": "Glosario", + "GITBOOK_LINK": "Publicado con GitBook", + "SUMMARY": "Tabla de contenido", + "SUMMARY_INTRODUCTION": "Introducción", + "SUMMARY_TOGGLE": "Tabla de contenido", + "SEARCH_TOGGLE": "Buscar", + "SEARCH_PLACEHOLDER": "Escribe para buscar", + "FONTSETTINGS_TOGGLE": "Configuración de fuente", + "SHARE_TOGGLE": "Compartir", + "SHARE_ON": "Compartir en {{platform}}", + "FONTSETTINGS_WHITE": "Claro", + "FONTSETTINGS_SEPIA": "Sépia", + "FONTSETTINGS_NIGHT": "Noche", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/fa.json b/packages/gitbook-plugin-theme-default/src/i18n/fa.json new file mode 100644 index 0000000..56ded4f --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/fa.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "انتخاب زبان", + "GLOSSARY": "واژهنامه", + "GLOSSARY_INDEX": "فهرست واژهها", + "GLOSSARY_OPEN": "واژهنامه", + "GITBOOK_LINK": "انتشار یافته توسط GitBook", + "SUMMARY": "فهرست مطالب", + "SUMMARY_INTRODUCTION": "مقدمه", + "SUMMARY_TOGGLE": "فهرست مطالب", + "SEARCH_TOGGLE": "جستجو", + "SEARCH_PLACEHOLDER": "چیزی برای جستجو بنویسید", + "FONTSETTINGS_TOGGLE": "تنظیمات فونت", + "SHARE_TOGGLE": "اشتراک", + "SHARE_ON": "در {{platform}} به اشتراک بگذارید", + "FONTSETTINGS_WHITE": "سفید", + "FONTSETTINGS_SEPIA": "سپیا", + "FONTSETTINGS_NIGHT": "شب", + "FONTSETTINGS_SANS": "سنس", + "FONTSETTINGS_SERIF": "سریف" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/fi.json b/packages/gitbook-plugin-theme-default/src/i18n/fi.json new file mode 100644 index 0000000..a8476ca --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/fi.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Valitse kieli", + "GLOSSARY": "Sanasto", + "GLOSSARY_INDEX": "Hakemisto", + "GLOSSARY_OPEN": "Sanasto", + "GITBOOK_LINK": "Julkaistu GitBookilla", + "SUMMARY": "Sisällysluettelo", + "SUMMARY_INTRODUCTION": "Johdanto", + "SUMMARY_TOGGLE": "Sisällysluettelu", + "SEARCH_TOGGLE": "Etsi", + "SEARCH_PLACEHOLDER": "Kirjoita hakusana", + "FONTSETTINGS_TOGGLE": "Fonttivalinnat", + "SHARE_TOGGLE": "Jaa", + "SHARE_ON": "Jaa {{platform}}ssa", + "FONTSETTINGS_WHITE": "Valkoinen", + "FONTSETTINGS_SEPIA": "Seepia", + "FONTSETTINGS_NIGHT": "Yö", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/fr.json b/packages/gitbook-plugin-theme-default/src/i18n/fr.json new file mode 100644 index 0000000..8cc10e2 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/fr.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Choisissez une langue", + "GLOSSARY": "Glossaire", + "GLOSSARY_INDEX": "Index", + "GLOSSARY_OPEN": "Glossaire", + "GITBOOK_LINK": "Publié avec GitBook", + "SUMMARY": "Table des matières", + "SUMMARY_INTRODUCTION": "Introduction", + "SUMMARY_TOGGLE": "Table des matières", + "SEARCH_TOGGLE": "Recherche", + "SEARCH_PLACEHOLDER": "Tapez pour rechercher", + "FONTSETTINGS_TOGGLE": "Paramètres de Police", + "SHARE_TOGGLE": "Partage", + "SHARE_ON": "Partager sur {{platform}}", + "FONTSETTINGS_WHITE": "Clair", + "FONTSETTINGS_SEPIA": "Sépia", + "FONTSETTINGS_NIGHT": "Nuit", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +}
\ No newline at end of file diff --git a/packages/gitbook-plugin-theme-default/src/i18n/he.json b/packages/gitbook-plugin-theme-default/src/i18n/he.json new file mode 100644 index 0000000..353d3b5 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/he.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "בחר שפה", + "GLOSSARY": "מונחים", + "GLOSSARY_INDEX": "מפתח", + "GLOSSARY_OPEN": "מונחים", + "GITBOOK_LINK": "הוצאה לאור באמצעות גיט-בוק GITBOOK", + "SUMMARY": "תוכן העניינים", + "SUMMARY_INTRODUCTION": "הוראות", + "SUMMARY_TOGGLE": "תקציר", + "SEARCH_TOGGLE": "חיפוש", + "SEARCH_PLACEHOLDER": "סוג החיפוש", + "FONTSETTINGS_TOGGLE": "הגדרת אותיות", + "SHARE_TOGGLE": "שתף", + "SHARE_ON": "{{platform}} שתף ב", + "FONTSETTINGS_WHITE": "בהיר", + "FONTSETTINGS_SEPIA": "חום כהה", + "FONTSETTINGS_NIGHT": "מצב לילה", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/index.js b/packages/gitbook-plugin-theme-default/src/i18n/index.js new file mode 100644 index 0000000..d09de1b --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/index.js @@ -0,0 +1,30 @@ + +module.exports = { + ar: require('./ar'), + bn: require('./bn'), + ca: require('./ca'), + cs: require('./cs'), + de: require('./de'), + el: require('./el'), + en: require('./en'), + es: require('./es'), + fa: require('./fa'), + fi: require('./fi'), + fr: require('./fr'), + he: require('./he'), + it: require('./it'), + ja: require('./ja'), + ko: require('./ko'), + nl: require('./nl'), + no: require('./no'), + pl: require('./pl'), + pt: require('./pt'), + ro: require('./ro'), + ru: require('./ru'), + sv: require('./sv'), + tr: require('./tr'), + uk: require('./uk'), + vi: require('./vi'), + 'zh-hans': require('./zh-hans'), + 'zh-tw': require('./zh-tw'), +}; diff --git a/packages/gitbook-plugin-theme-default/src/i18n/it.json b/packages/gitbook-plugin-theme-default/src/i18n/it.json new file mode 100644 index 0000000..3f5e95d --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/it.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Scegli una lingua", + "GLOSSARY": "Glossario", + "GLOSSARY_INDEX": "Indice", + "GLOSSARY_OPEN": "Glossario", + "GITBOOK_LINK": "Pubblicato con GitBook", + "SUMMARY": "Sommario", + "SUMMARY_INTRODUCTION": "Introduzione", + "SUMMARY_TOGGLE": "Sommario", + "SEARCH_TOGGLE": "Cerca", + "SEARCH_PLACEHOLDER": "Scrivi per cercare", + "FONTSETTINGS_TOGGLE": "Impostazioni dei caratteri", + "SHARE_TOGGLE": "Condividi", + "SHARE_ON": "Condividi su {{platform}}", + "FONTSETTINGS_WHITE": "Bianco", + "FONTSETTINGS_SEPIA": "Seppia", + "FONTSETTINGS_NIGHT": "Notte", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +}
\ No newline at end of file diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ja.json b/packages/gitbook-plugin-theme-default/src/i18n/ja.json new file mode 100644 index 0000000..b1afd02 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/ja.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "言語を選択", + "GLOSSARY": "用語集", + "GLOSSARY_INDEX": "索引", + "GLOSSARY_OPEN": "用語集", + "GITBOOK_LINK": "GitBookで公開 ", + "SUMMARY": "目次", + "SUMMARY_INTRODUCTION": "はじめに", + "SUMMARY_TOGGLE": "目次", + "SEARCH_TOGGLE": "検索", + "SEARCH_PLACEHOLDER": "検索すると入力", + "FONTSETTINGS_TOGGLE": "フォント設定", + "SHARE_TOGGLE": "シェア", + "SHARE_ON": "{{platform}}でシェア", + "FONTSETTINGS_WHITE": "白", + "FONTSETTINGS_SEPIA": "セピア", + "FONTSETTINGS_NIGHT": "夜", + "FONTSETTINGS_SANS": "ゴシック体", + "FONTSETTINGS_SERIF": "明朝体" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ko.json b/packages/gitbook-plugin-theme-default/src/i18n/ko.json new file mode 100644 index 0000000..5015a93 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/ko.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "언어를 선택하세요", + "GLOSSARY": "어휘", + "GLOSSARY_INDEX": "색인", + "GLOSSARY_OPEN": "어휘", + "GITBOOK_LINK": "GitBook에 게시", + "SUMMARY": "차례", + "SUMMARY_INTRODUCTION": "소개", + "SUMMARY_TOGGLE": "차례", + "SEARCH_TOGGLE": "검색", + "SEARCH_PLACEHOLDER": "검색어 입력", + "FONTSETTINGS_TOGGLE": "글꼴 설정", + "SHARE_TOGGLE": "공유", + "SHARE_ON": "{{platform}}에 공유", + "FONTSETTINGS_WHITE": "화이트", + "FONTSETTINGS_SEPIA": "세피아", + "FONTSETTINGS_NIGHT": "나이트", + "FONTSETTINGS_SANS": "고딕", + "FONTSETTINGS_SERIF": "명조" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/nl.json b/packages/gitbook-plugin-theme-default/src/i18n/nl.json new file mode 100644 index 0000000..da4f59e --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/nl.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Kies een taal", + "GLOSSARY": "Begrippenlijst", + "GLOSSARY_INDEX": "Index", + "GLOSSARY_OPEN": "Begrippenlijst", + "GITBOOK_LINK": "Gepubliceerd met GitBook", + "SUMMARY": "Inhoudsopgave", + "SUMMARY_INTRODUCTION": "Inleiding", + "SUMMARY_TOGGLE": "Inhoudsopgave", + "SEARCH_TOGGLE": "Zoeken", + "SEARCH_PLACEHOLDER": "Zoeken", + "FONTSETTINGS_TOGGLE": "Lettertype instellingen", + "SHARE_TOGGLE": "Delen", + "SHARE_ON": "Delen op {{platform}}", + "FONTSETTINGS_WHITE": "Wit", + "FONTSETTINGS_SEPIA": "Sepia", + "FONTSETTINGS_NIGHT": "Zwart", + "FONTSETTINGS_SANS": "Schreefloos", + "FONTSETTINGS_SERIF": "Schreef" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/no.json b/packages/gitbook-plugin-theme-default/src/i18n/no.json new file mode 100644 index 0000000..1ed6236 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/no.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Velg språk", + "GLOSSARY": "Register", + "GLOSSARY_INDEX": "Indeks", + "GLOSSARY_OPEN": "Register", + "GITBOOK_LINK": "Publisert med GitBook", + "SUMMARY": "Innholdsfortegnelse", + "SUMMARY_INTRODUCTION": "Innledning", + "SUMMARY_TOGGLE": "Innholdsfortegnelse", + "SEARCH_TOGGLE": "Søk", + "SEARCH_PLACEHOLDER": "Skriv inn søkeord", + "FONTSETTINGS_TOGGLE": "Tekstinnstillinger", + "SHARE_TOGGLE": "Del", + "SHARE_ON": "Del på {{platform}}", + "FONTSETTINGS_WHITE": "Lys", + "FONTSETTINGS_SEPIA": "Sepia", + "FONTSETTINGS_NIGHT": "Mørk", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/pl.json b/packages/gitbook-plugin-theme-default/src/i18n/pl.json new file mode 100644 index 0000000..4f009fc --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/pl.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Wybierz język", + "GLOSSARY": "Glosariusz", + "GLOSSARY_INDEX": "Indeks", + "GLOSSARY_OPEN": "Glosariusz", + "GITBOOK_LINK": "Opublikowano dzięki GitBook", + "SUMMARY": "Spis treści", + "SUMMARY_INTRODUCTION": "Wstęp", + "SUMMARY_TOGGLE": "Spis treści", + "SEARCH_TOGGLE": "Szukaj", + "SEARCH_PLACEHOLDER": "Wpisz szukaną frazę", + "FONTSETTINGS_TOGGLE": "Ustawienia czcionki", + "SHARE_TOGGLE": "Udostępnij", + "SHARE_ON": "Udostępnij na {{platform}}", + "FONTSETTINGS_WHITE": "Jasny", + "FONTSETTINGS_SEPIA": "Sepia", + "FONTSETTINGS_NIGHT": "Noc", + "FONTSETTINGS_SANS": "Bezszeryfowa", + "FONTSETTINGS_SERIF": "Szeryfowa" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/pt.json b/packages/gitbook-plugin-theme-default/src/i18n/pt.json new file mode 100644 index 0000000..9d6bde0 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/pt.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Escolher sua língua", + "GLOSSARY": "Glossário", + "GLOSSARY_INDEX": "Índice", + "GLOSSARY_OPEN": "Glossário", + "GITBOOK_LINK": "Publicado com GitBook", + "SUMMARY": "Tabela de conteúdos", + "SUMMARY_INTRODUCTION": "Introdução", + "SUMMARY_TOGGLE": "Tabela de conteúdos", + "SEARCH_TOGGLE": "Pesquise", + "SEARCH_PLACEHOLDER": "Escreva para pesquisar", + "FONTSETTINGS_TOGGLE": "Configurações de fonte", + "SHARE_TOGGLE": "Compartilhar", + "SHARE_ON": "Compartilhar no {{platform}}", + "FONTSETTINGS_WHITE": "Claro", + "FONTSETTINGS_SEPIA": "Sépia", + "FONTSETTINGS_NIGHT": "Noite", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ro.json b/packages/gitbook-plugin-theme-default/src/i18n/ro.json new file mode 100644 index 0000000..24295a4 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/ro.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Alege o limba", + "GLOSSARY": "Glosar", + "GLOSSARY_INDEX": "Index", + "GLOSSARY_OPEN": "Glosar", + "GITBOOK_LINK": "Publicata cu GitBook", + "SUMMARY": "Cuprins", + "SUMMARY_INTRODUCTION": "Introducere", + "SUMMARY_TOGGLE": "Cuprins", + "SEARCH_TOGGLE": "Cauta", + "SEARCH_PLACEHOLDER": "Ce cauti", + "FONTSETTINGS_TOGGLE": "Setari de font", + "SHARE_TOGGLE": "Distribuie", + "SHARE_ON": "Distribuie pe {{platform}}", + "FONTSETTINGS_WHITE": "Alb", + "FONTSETTINGS_SEPIA": "Sepia", + "FONTSETTINGS_NIGHT": "Noapte", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ru.json b/packages/gitbook-plugin-theme-default/src/i18n/ru.json new file mode 100644 index 0000000..9e6b9dd --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/ru.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Выберите язык", + "GLOSSARY": "Алфавитный указатель", + "GLOSSARY_INDEX": "Алфавитный указатель", + "GLOSSARY_OPEN": "Алфавитный указатель", + "GITBOOK_LINK": "Опубликовано с помощью GitBook", + "SUMMARY": "Содержание", + "SUMMARY_INTRODUCTION": "Введение", + "SUMMARY_TOGGLE": "Содержание", + "SEARCH_TOGGLE": "Поиск", + "SEARCH_PLACEHOLDER": "Введите условия поиска", + "FONTSETTINGS_TOGGLE": "Шрифт", + "SHARE_TOGGLE": "Поделиться", + "SHARE_ON": "Поделиться в {{platform}}", + "FONTSETTINGS_WHITE": "Светлый", + "FONTSETTINGS_SEPIA": "Сепия", + "FONTSETTINGS_NIGHT": "Тёмный", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +}
\ No newline at end of file diff --git a/packages/gitbook-plugin-theme-default/src/i18n/sv.json b/packages/gitbook-plugin-theme-default/src/i18n/sv.json new file mode 100644 index 0000000..2e2f6ac --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/sv.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Välj språk", + "GLOSSARY": "Gloslista", + "GLOSSARY_INDEX": "Index", + "GLOSSARY_OPEN": "Gloslista", + "GITBOOK_LINK": "Publicera med GitBook", + "SUMMARY": "Innehållsförteckning", + "SUMMARY_INTRODUCTION": "Inledning", + "SUMMARY_TOGGLE": "Innehållsförteckning", + "SEARCH_TOGGLE": "Sök", + "SEARCH_PLACEHOLDER": "Skriv sökord", + "FONTSETTINGS_TOGGLE": "Textinställningar", + "SHARE_TOGGLE": "Dela", + "SHARE_ON": "Dela på {{platform}}", + "FONTSETTINGS_WHITE": "Ljus", + "FONTSETTINGS_SEPIA": "Sepia", + "FONTSETTINGS_NIGHT": "Mörk", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/tr.json b/packages/gitbook-plugin-theme-default/src/i18n/tr.json new file mode 100644 index 0000000..d92d5a2 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/tr.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Dil seçimi", + "GLOSSARY": "Sözlük", + "GLOSSARY_INDEX": "Dizin", + "GLOSSARY_OPEN": "Sözlük", + "GITBOOK_LINK": "GitBook ile yayınla", + "SUMMARY": "İçindekiler", + "SUMMARY_INTRODUCTION": "Giriş", + "SUMMARY_TOGGLE": "İçindekiler", + "SEARCH_TOGGLE": "Arama", + "SEARCH_PLACEHOLDER": "Aramak istediğiniz", + "FONTSETTINGS_TOGGLE": "Font Ayarları", + "SHARE_TOGGLE": "Paylaş", + "SHARE_ON": "{{platform}} ile paylaş", + "FONTSETTINGS_WHITE": "Beyaz", + "FONTSETTINGS_SEPIA": "Sepya", + "FONTSETTINGS_NIGHT": "Karanlık", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/uk.json b/packages/gitbook-plugin-theme-default/src/i18n/uk.json new file mode 100644 index 0000000..a582d6c --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/uk.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Виберіть мову", + "GLOSSARY": "Алфавітний покажчик", + "GLOSSARY_INDEX": "Алфавітний покажчик", + "GLOSSARY_OPEN": "Алфавітний покажчик", + "GITBOOK_LINK": "Опубліковано за допомогою GitBook", + "SUMMARY": "Зміст", + "SUMMARY_INTRODUCTION": "Вступ", + "SUMMARY_TOGGLE": "Зміст", + "SEARCH_TOGGLE": "Пошук", + "SEARCH_PLACEHOLDER": "Введіть для пошуку", + "FONTSETTINGS_TOGGLE": "Шрифт", + "SHARE_TOGGLE": "Поділитися", + "SHARE_ON": "Поділитися в {{platform}}", + "FONTSETTINGS_WHITE": "Світлий", + "FONTSETTINGS_SEPIA": "Сепія", + "FONTSETTINGS_NIGHT": "Темний", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +}
\ No newline at end of file diff --git a/packages/gitbook-plugin-theme-default/src/i18n/vi.json b/packages/gitbook-plugin-theme-default/src/i18n/vi.json new file mode 100644 index 0000000..0addb8e --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/vi.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "Lựa chọn ngôn ngữ", + "GLOSSARY": "Chú giải", + "GLOSSARY_INDEX": "Chỉ mục", + "GLOSSARY_OPEN": "Chú giải", + "GITBOOK_LINK": "Xuất bản với GitBook", + "SUMMARY": "Mục Lục", + "SUMMARY_INTRODUCTION": "Giới thiệu", + "SUMMARY_TOGGLE": "Mục Lục", + "SEARCH_TOGGLE": "Tìm kiếm", + "SEARCH_PLACEHOLDER": "Nhập thông tin cần tìm", + "FONTSETTINGS_TOGGLE": "Tùy chỉnh phông chữ", + "SHARE_TOGGLE": "Chia sẻ", + "SHARE_ON": "Chia sẻ trên {{platform}}", + "FONTSETTINGS_WHITE": "Sáng", + "FONTSETTINGS_SEPIA": "Vàng nâu", + "FONTSETTINGS_NIGHT": "Tối", + "FONTSETTINGS_SANS": "Sans", + "FONTSETTINGS_SERIF": "Serif" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/zh-hans.json b/packages/gitbook-plugin-theme-default/src/i18n/zh-hans.json new file mode 100644 index 0000000..8aa372c --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/zh-hans.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "选择一种语言", + "GLOSSARY": "术语表", + "GLOSSARY_INDEX": "索引", + "GLOSSARY_OPEN": "术语表", + "GITBOOK_LINK": "本书使用 GitBook 发布", + "SUMMARY": "目录", + "SUMMARY_INTRODUCTION": "介绍", + "SUMMARY_TOGGLE": "目录", + "SEARCH_TOGGLE": "搜索", + "SEARCH_PLACEHOLDER": "输入并搜索", + "FONTSETTINGS_TOGGLE": "字体设置", + "SHARE_TOGGLE": "分享", + "SHARE_ON": "分享到 {{platform}}", + "FONTSETTINGS_WHITE": "白色", + "FONTSETTINGS_SEPIA": "棕褐色", + "FONTSETTINGS_NIGHT": "夜间", + "FONTSETTINGS_SANS": "无衬线体", + "FONTSETTINGS_SERIF": "衬线体" +} diff --git a/packages/gitbook-plugin-theme-default/src/i18n/zh-tw.json b/packages/gitbook-plugin-theme-default/src/i18n/zh-tw.json new file mode 100644 index 0000000..d5ff1ad --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/i18n/zh-tw.json @@ -0,0 +1,20 @@ +{ + "LANGS_CHOOSE": "選擇一種語言", + "GLOSSARY": "術語表", + "GLOSSARY_INDEX": "索引", + "GLOSSARY_OPEN": "術語表", + "GITBOOK_LINK": "本書使用 GitBook 釋出", + "SUMMARY": "目錄", + "SUMMARY_INTRODUCTION": "介紹", + "SUMMARY_TOGGLE": "目錄", + "SEARCH_TOGGLE": "搜尋", + "SEARCH_PLACEHOLDER": "輸入並搜尋", + "FONTSETTINGS_TOGGLE": "字型設定", + "SHARE_TOGGLE": "分享", + "SHARE_ON": "分享到 {{platform}}", + "FONTSETTINGS_WHITE": "白色", + "FONTSETTINGS_SEPIA": "棕褐色", + "FONTSETTINGS_NIGHT": "夜間", + "FONTSETTINGS_SANS": "無襯線體", + "FONTSETTINGS_SERIF": "襯線體" +} diff --git a/packages/gitbook-plugin-theme-default/src/index.js b/packages/gitbook-plugin-theme-default/src/index.js new file mode 100644 index 0000000..ad96175 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/index.js @@ -0,0 +1,14 @@ +const GitBook = require('gitbook-core'); + +const Theme = require('./components/Theme'); +const reduceState = require('./reducers'); +const locales = require('./i18n'); + + +module.exports = GitBook.createPlugin({ + activate: (dispatch, state, { Components, I18n }) => { + dispatch(Components.registerComponent(Theme, { role: 'website:body' })); + dispatch(I18n.registerLocales(locales)); + }, + reduce: reduceState +}); diff --git a/packages/gitbook-plugin-theme-default/src/reducers/index.js b/packages/gitbook-plugin-theme-default/src/reducers/index.js new file mode 100644 index 0000000..ac53d3a --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/reducers/index.js @@ -0,0 +1,5 @@ +const GitBook = require('gitbook-core'); + +module.exports = GitBook.composeReducer( + GitBook.createReducer('sidebar', require('./sidebar')) +); diff --git a/packages/gitbook-plugin-theme-default/src/reducers/sidebar.js b/packages/gitbook-plugin-theme-default/src/reducers/sidebar.js new file mode 100644 index 0000000..eef68d4 --- /dev/null +++ b/packages/gitbook-plugin-theme-default/src/reducers/sidebar.js @@ -0,0 +1,18 @@ +const GitBook = require('gitbook-core'); +const { Record } = GitBook.Immutable; +const ActionTypes = require('../actions/types'); + +const SidebarState = Record({ + open: true +}); + +function reduceSidebar(state = SidebarState(), action) { + switch (action.type) { + case ActionTypes.TOGGLE_SIDEBAR: + return state.set('open', !state.get('open')); + default: + return state; + } +} + +module.exports = reduceSidebar; diff --git a/packages/gitbook-plugin/CONTRIBUTING.md b/packages/gitbook-plugin/CONTRIBUTING.md new file mode 100644 index 0000000..19119b6 --- /dev/null +++ b/packages/gitbook-plugin/CONTRIBUTING.md @@ -0,0 +1,11 @@ +Compile the CLI using: + +``` +npm run dist +``` + +Then run the CLI in `lib/`: + +``` +./lib/cli.js +``` diff --git a/packages/gitbook-plugin/README.md b/packages/gitbook-plugin/README.md new file mode 100644 index 0000000..b2dab88 --- /dev/null +++ b/packages/gitbook-plugin/README.md @@ -0,0 +1 @@ +For instructions on how to create plugins, see [GitBook: Create a plugin](toolchain.gitbook.com/api/). diff --git a/packages/gitbook-plugin/package.json b/packages/gitbook-plugin/package.json new file mode 100644 index 0000000..9cdc2f8 --- /dev/null +++ b/packages/gitbook-plugin/package.json @@ -0,0 +1,39 @@ +{ + "name": "gitbook-plugin", + "version": "4.0.0", + "description": "CLI for compiling and testing plugins", + "main": "./lib/index.js", + "dependencies": { + "babel-preset-es2015": "^6.14.0", + "babel-preset-react": "^6.11.1", + "babelify": "^7.3.0", + "browserify": "^13.1.0", + "commander": "^2.9.0", + "fs-extra": "^0.30.0", + "inquirer": "^1.1.3", + "q": "^1.4.1", + "winston": "^2.2.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" + }, + "bin": { + "gitbook-plugin": "./lib/cli.js" + }, + "scripts": { + "dist": "rm -rf lib/ && babel -d lib/ src/ && chmod +x ./lib/cli.js", + "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-plugin/src/cli.js b/packages/gitbook-plugin/src/cli.js new file mode 100644 index 0000000..06e421d --- /dev/null +++ b/packages/gitbook-plugin/src/cli.js @@ -0,0 +1,84 @@ +#! /usr/bin/env node + +const program = require('commander'); +const path = require('path'); +const winston = require('winston'); +const inquirer = require('inquirer'); + +const pkg = require('../package.json'); +const compile = require('./compile'); +const create = require('./create'); + +const resolve = (input => path.resolve(process.cwd(), input)); + +program.version(pkg.version); +winston.cli(); + +program + .command('build [input] [output]') + .description('build a browser plugin') + .action(function(input, output, options) { + compile(resolve(input), resolve(output)) + .then( + () => winston.info('Plugin compiled successfully'), + (err) => winston.error('Error: ', err) + ); + }); + +program + .command('create [output]') + .description('create a new plugin') + .action(function(output, options) { + inquirer.prompt([ + { + name: 'title', + message: 'Title (as displayed on GitBook.com):' + }, + { + name: 'name', + message: 'Name (unique identifier for the plugin):' + }, + { + name: 'desc', + message: 'Description:' + }, + { + name: 'github', + message: 'GitHub repository URL:' + }, + { + name: 'categories', + message: 'Categories (as displayed on GitBook.com):', + type: 'checkbox', + choices: [ + 'analytics', + 'search', + 'content', + 'structure', + 'social', + 'visual' + ] + } + ]) + .then(answers => { + output = resolve(output || answers.name); + return create(output, answers); + }) + .then( + () => winston.info(`Plugin created successfully in "${output}"`), + (err) => winston.error('Error: ', err) + ); + }); + +program + .command('test [plugin]') + .description('test specs for a plugin') + .action(function(plugin, options) { + + }); + + +program.parse(process.argv); + +// Display help if no arguments +if (!program.args.length) program.help(); diff --git a/packages/gitbook-plugin/src/compile.js b/packages/gitbook-plugin/src/compile.js new file mode 100644 index 0000000..61c8777 --- /dev/null +++ b/packages/gitbook-plugin/src/compile.js @@ -0,0 +1,41 @@ +const fs = require('fs-extra'); +const Promise = require('q'); +const browserify = require('browserify'); +const babelify = require('babelify'); + +/** + * Compile a plugin to work with "gitbook-core" in the browser. + * @param {String} inputFile + * @param {String} outputFile + * @return {Promise} + */ +function compilePlugin(inputFile, outputFile) { + const d = Promise.defer(); + const b = browserify({ + standalone: 'GitBookPlugin' + }); + + b.add(inputFile); + b.external('react'); + b.external('react-dom'); + b.external('gitbook-core'); + b.transform(babelify, { + presets: [ + require('babel-preset-es2015'), + require('babel-preset-react') + ] + }); + + fs.ensureFileSync(outputFile); + + const output = fs.createWriteStream(outputFile); + + b.bundle() + .pipe(output) + .on('error', (err) => d.reject(err)) + .on('end', () => d.resolve()); + + return d.promise; +} + +module.exports = compilePlugin; diff --git a/packages/gitbook-plugin/src/create.js b/packages/gitbook-plugin/src/create.js new file mode 100644 index 0000000..31edb85 --- /dev/null +++ b/packages/gitbook-plugin/src/create.js @@ -0,0 +1,61 @@ +const fs = require('fs-extra'); +const path = require('path'); +const GITBOOK_VERSION = require('../package.json').version; + +const TEMPLATE_DIR = path.resolve(__dirname, '../template'); + +/** + * Create a new plugin + * @param {String} outputDir + * @param {String} spec.title + * @param {String} spec.name + * @param {String} spec.desc + * @param {Array} spec.keywords + */ +function create(outputDir, spec) { + const pkg = { + 'title': `${spec.title}`, + 'name': `gitbook-plugin-${spec.name}`, + 'description': `${spec.desc}`, + 'version': '0.0.0', + 'main': 'index.js', + 'browser': './_assets/plugin.js', + 'ebook': './_assets/plugin.js', + 'dependencies': { + 'gitbook-core': '^' + GITBOOK_VERSION + }, + 'devDependencies': { + 'gitbook-plugin': '^' + GITBOOK_VERSION, + 'eslint': '3.7.1', + 'eslint-config-gitbook': '1.4.0' + }, + 'engines': { + 'gitbook': '>=4.0.0-alpha.0' + }, + 'scripts': { + 'lint': 'eslint ./', + 'build-website': 'gitbook-plugin build ./src/index.js ./_assets/plugin.js', + 'prepublish': 'npm run build-website', + 'test': 'gitbook-plugin test && npm run lint' + }, + 'homepage': `${spec.github}`, + 'keywords': spec.categories.map(category => `gitbook:${category}`), + 'repository': { + 'type': 'git', + 'url': `${spec.github}.git` + }, + 'bugs': { + 'url': `${spec.github}/issues` + } + }; + + fs.copySync(TEMPLATE_DIR, outputDir, { + clobber: true + }); + + fs.outputJsonSync(path.resolve(outputDir, 'package.json'), pkg, { + spaces: 2 + }); +} + +module.exports = create; diff --git a/packages/gitbook-plugin/src/index.js b/packages/gitbook-plugin/src/index.js new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/gitbook-plugin/src/index.js diff --git a/packages/gitbook-plugin/template/.eslintignore b/packages/gitbook-plugin/template/.eslintignore new file mode 100644 index 0000000..1d35cda --- /dev/null +++ b/packages/gitbook-plugin/template/.eslintignore @@ -0,0 +1,2 @@ +# Plugin assets +_assets/plugin.js diff --git a/packages/gitbook-plugin/template/.eslintrc b/packages/gitbook-plugin/template/.eslintrc new file mode 100644 index 0000000..90359b2 --- /dev/null +++ b/packages/gitbook-plugin/template/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "gitbook/plugin" +} diff --git a/packages/gitbook-plugin/template/.gitignore b/packages/gitbook-plugin/template/.gitignore new file mode 100644 index 0000000..ef47881 --- /dev/null +++ b/packages/gitbook-plugin/template/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Deployed apps should consider commenting this line out: +# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git +node_modules + +# vim swapfile +*.swp + +# Plugin assets +_assets/plugin.js diff --git a/packages/gitbook-plugin/template/.npmignore b/packages/gitbook-plugin/template/.npmignore new file mode 100644 index 0000000..a0e53cf --- /dev/null +++ b/packages/gitbook-plugin/template/.npmignore @@ -0,0 +1,2 @@ +# Publish assets on NPM +!_assets/plugin.js diff --git a/packages/gitbook-plugin/template/index.js b/packages/gitbook-plugin/template/index.js new file mode 100644 index 0000000..e542ae8 --- /dev/null +++ b/packages/gitbook-plugin/template/index.js @@ -0,0 +1,10 @@ + +module.exports = { + blocks: { + + }, + + hooks: { + + } +}; diff --git a/packages/gitbook-plugin/template/src/index.js b/packages/gitbook-plugin/template/src/index.js new file mode 100644 index 0000000..0fe8869 --- /dev/null +++ b/packages/gitbook-plugin/template/src/index.js @@ -0,0 +1,11 @@ +const GitBook = require('gitbook-core'); + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState) => { + // Dispatch initialization actions + }, + deactivate: (dispatch, getState) => { + // Dispatch cleanup actions + }, + reduce: (state, action) => state +}); diff --git a/packages/gitbook/.babelrc b/packages/gitbook/.babelrc new file mode 100644 index 0000000..5f27bda --- /dev/null +++ b/packages/gitbook/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "react", "stage-2"] +} diff --git a/packages/gitbook/.gitignore b/packages/gitbook/.gitignore new file mode 100644 index 0000000..a65b417 --- /dev/null +++ b/packages/gitbook/.gitignore @@ -0,0 +1 @@ +lib diff --git a/packages/gitbook/.npmignore b/packages/gitbook/.npmignore new file mode 100644 index 0000000..e04684f --- /dev/null +++ b/packages/gitbook/.npmignore @@ -0,0 +1,2 @@ +src +!lib diff --git a/packages/gitbook/bin/gitbook.js b/packages/gitbook/bin/gitbook.js new file mode 100755 index 0000000..0492d29 --- /dev/null +++ b/packages/gitbook/bin/gitbook.js @@ -0,0 +1,8 @@ +#! /usr/bin/env node +/* eslint-disable no-console,no-var */ + +var color = require('bash-color'); + +console.log(color.red('You need to install "gitbook-cli" to have access to the gitbook command anywhere on your system.')); +console.log(color.red('If you\'ve installed this package globally, you need to uninstall it.')); +console.log(color.red('>> Run "npm uninstall -g gitbook" then "npm install -g gitbook-cli"')); diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json new file mode 100644 index 0000000..0e85179 --- /dev/null +++ b/packages/gitbook/package.json @@ -0,0 +1,107 @@ +{ + "name": "gitbook", + "version": "4.0.0", + "homepage": "https://www.gitbook.com", + "description": "Library and cmd utility to generate GitBooks", + "main": "lib/index.js", + "browser": "./lib/browser.js", + "dependencies": { + "bash-color": "0.0.4", + "cheerio": "0.20.0", + "chokidar": "1.5.0", + "cp": "0.2.0", + "cpr": "1.1.1", + "crc": "3.4.0", + "destroy": "1.0.4", + "direction": "0.1.5", + "dom-serializer": "0.1.0", + "error": "7.0.2", + "escape-html": "^1.0.3", + "escape-string-regexp": "1.0.5", + "extend": "^3.0.0", + "fresh-require": "1.0.3", + "front-matter": "^2.1.0", + "gitbook-asciidoc": "1.2.2", + "gitbook-core": "4.0.0", + "gitbook-markdown": "1.3.2", + "gitbook-plugin-copy-code": "4.0.0", + "gitbook-plugin-headings": "4.0.0", + "gitbook-plugin-highlight": "4.0.0", + "gitbook-plugin-hints": "4.0.0", + "gitbook-plugin-livereload": "4.0.0", + "gitbook-plugin-lunr": "4.0.0", + "gitbook-plugin-search": "4.0.0", + "gitbook-plugin-sharing": "4.0.0", + "gitbook-plugin-theme-default": "4.0.0", + "github-slugid": "1.0.1", + "graceful-fs": "4.1.4", + "i18n-t": "1.0.1", + "ied": "2.3.6", + "ignore": "3.1.2", + "immutable": "^3.8.1", + "is": "^3.1.0", + "js-yaml": "^3.6.1", + "json-schema-defaults": "0.1.1", + "jsonschema": "1.1.0", + "juice": "2.0.0", + "mkdirp": "0.5.1", + "moment": "2.13.0", + "npm": "3.10.9", + "nunjucks": "2.5.2", + "object-path": "^0.9.2", + "omit-keys": "^0.1.0", + "open": "0.0.5", + "q": "1.4.1", + "read-installed": "^4.0.3", + "redux": "^3.5.2", + "request": "2.72.0", + "resolve": "1.1.7", + "rmdir": "1.2.0", + "semver": "5.1.0", + "send": "0.13.2", + "spawn-cmd": "0.0.2", + "tiny-lr": "0.2.1", + "tmp": "0.0.28", + "urijs": "1.18.0" + }, + "scripts": { + "test": "./node_modules/.bin/mocha ./testing/setup.js \"./src/**/*/__tests__/*.js\" --bail --reporter=list --timeout=100000 --compilers js:babel-register", + "dist": "rm -rf lib/ && babel -d lib/ src/ --source-maps --ignore \"**/*/__tests__/*.js\"", + "prepublish": "npm run dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "bin": { + "gitbook": "./bin/gitbook.js" + }, + "keywords": [ + "git", + "book", + "gitbook" + ], + "author": "GitBook Inc. <contact@gitbook.com>", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + }, + "contributors": [ + { + "name": "Aaron O'Mullan", + "email": "aaron@gitbook.com" + }, + { + "name": "Samy Pessé", + "email": "samy@gitbook.com" + } + ], + "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", + "babel-register": "^6.14.0", + "mocha": "^3.0.2" + } +} diff --git a/packages/gitbook/src/__tests__/gitbook.js b/packages/gitbook/src/__tests__/gitbook.js new file mode 100644 index 0000000..5292e01 --- /dev/null +++ b/packages/gitbook/src/__tests__/gitbook.js @@ -0,0 +1,9 @@ +const gitbook = require('../gitbook'); + +describe('satisfies', function() { + + it('should return true for *', function() { + expect(gitbook.satisfies('*')).toBe(true); + }); + +}); diff --git a/packages/gitbook/src/__tests__/init.js b/packages/gitbook/src/__tests__/init.js new file mode 100644 index 0000000..d8e5398 --- /dev/null +++ b/packages/gitbook/src/__tests__/init.js @@ -0,0 +1,16 @@ +const tmp = require('tmp'); +const initBook = require('../init'); + +describe('initBook', function() { + + it('should create a README and SUMMARY for empty book', function() { + const dir = tmp.dirSync(); + + return initBook(dir.name) + .then(function() { + expect(dir.name).toHaveFile('README.md'); + expect(dir.name).toHaveFile('SUMMARY.md'); + }); + }); + +}); diff --git a/packages/gitbook/src/__tests__/module.js b/packages/gitbook/src/__tests__/module.js new file mode 100644 index 0000000..d9220f5 --- /dev/null +++ b/packages/gitbook/src/__tests__/module.js @@ -0,0 +1,6 @@ + +describe('GitBook', function() { + it('should correctly export', function() { + require('../'); + }); +}); diff --git a/packages/gitbook/src/api/decodeConfig.js b/packages/gitbook/src/api/decodeConfig.js new file mode 100644 index 0000000..0c5ba66 --- /dev/null +++ b/packages/gitbook/src/api/decodeConfig.js @@ -0,0 +1,17 @@ +/** + Decode changes from a JS API to a config object + + @param {Config} config + @param {Object} result: result from API + @return {Config} +*/ +function decodeGlobal(config, result) { + const values = result.values; + + delete values.generator; + delete values.output; + + return config.updateValues(values); +} + +module.exports = decodeGlobal; diff --git a/packages/gitbook/src/api/decodeGlobal.js b/packages/gitbook/src/api/decodeGlobal.js new file mode 100644 index 0000000..c7bbcc7 --- /dev/null +++ b/packages/gitbook/src/api/decodeGlobal.js @@ -0,0 +1,22 @@ +const decodeConfig = require('./decodeConfig'); + +/** + * Decode changes from a JS API to a output object. + * Only the configuration can be edited by plugin's hooks + * + * @param {Output} output + * @param {Object} result: result from API + * @return {Output} output + */ +function decodeGlobal(output, result) { + let book = output.getBook(); + let config = book.getConfig(); + + // Update config + config = decodeConfig(config, result.config); + book = book.set('config', config); + + return output.set('book', book); +} + +module.exports = decodeGlobal; diff --git a/packages/gitbook/src/api/decodePage.js b/packages/gitbook/src/api/decodePage.js new file mode 100644 index 0000000..16e5115 --- /dev/null +++ b/packages/gitbook/src/api/decodePage.js @@ -0,0 +1,34 @@ +const Immutable = require('immutable'); + +/** + * Decode changes from a JS API to a page object. + * Only the content can be edited by plugin's hooks. + * + * @param {Output} output + * @param {Page} page: page instance to edit + * @param {Object} result: result from API + * @return {Page} + */ +function decodePage(output, page, result) { + const originalContent = page.getContent(); + + // No returned value + // Existing content will be used + if (!result) { + return page; + } + + // Update page attributes + const newAttributes = Immutable.fromJS(result.attributes); + page = page.set('attributes', newAttributes); + + // GitBook 3 + // Use returned page.content if different from original content + if (result.content != originalContent) { + page = page.set('content', result.content); + } + + return page; +} + +module.exports = decodePage; diff --git a/packages/gitbook/src/api/deprecate.js b/packages/gitbook/src/api/deprecate.js new file mode 100644 index 0000000..c781971 --- /dev/null +++ b/packages/gitbook/src/api/deprecate.js @@ -0,0 +1,120 @@ +const is = require('is'); +const objectPath = require('object-path'); + +const logged = {}; +const disabled = {}; + +/** + * Log a deprecated notice + * + * @param {Book|Output} book + * @param {String} key + * @param {String} message + */ +function logNotice(book, key, message) { + if (logged[key] || disabled[key]) return; + + logged[key] = true; + + const logger = book.getLogger(); + logger.warn.ln(message); +} + +/** + * Deprecate a function + * + * @param {Book|Output} book + * @param {String} key: unique identitifer for the deprecated + * @param {Function} fn + * @param {String} msg: message to print when called + * @return {Function} + */ +function deprecateMethod(book, key, fn, msg) { + return function(...args) { + logNotice(book, key, msg); + return fn.apply(this, args); + }; +} + +/** + * Deprecate a property of an object + * + * @param {Book|Output} book + * @param {String} key: unique identitifer for the deprecated + * @param {Object} instance + * @param {String|Function} property + * @param {String} msg: message to print when called + * @return {Function} + */ +function deprecateField(book, key, instance, property, value, msg) { + let store = undefined; + + const prepare = () => { + if (!is.undefined(store)) return; + + if (is.fn(value)) store = value(); + else store = value; + }; + + const getter = () => { + prepare(); + + logNotice(book, key, msg); + return store; + }; + + const setter = (v) => { + prepare(); + + logNotice(book, key, msg); + store = v; + return store; + }; + + Object.defineProperty(instance, property, { + get: getter, + set: setter, + enumerable: false, + configurable: true + }); +} + +/** + * Enable a deprecation + * @param {String} key: unique identitifer + */ +function enableDeprecation(key) { + disabled[key] = false; +} + +/** + * Disable a deprecation + * @param {String} key: unique identitifer + */ +function disableDeprecation(key) { + disabled[key] = true; +} + +/** + * Deprecate a method in favor of another one. + * + * @param {Book} book + * @param {String} key + * @param {Object} instance + * @param {String} oldName + * @param {String} newName + */ +function deprecateRenamedMethod(book, key, instance, oldName, newName, msg) { + msg = msg || ('"' + oldName + '" is deprecated, use "' + newName + '()" instead'); + const fn = objectPath.get(instance, newName); + + instance[oldName] = deprecateMethod(book, key, fn, msg); +} + +module.exports = { + method: deprecateMethod, + renamedMethod: deprecateRenamedMethod, + field: deprecateField, + enable: enableDeprecation, + disable: disableDeprecation +}; diff --git a/packages/gitbook/src/api/encodeConfig.js b/packages/gitbook/src/api/encodeConfig.js new file mode 100644 index 0000000..cdfc0b7 --- /dev/null +++ b/packages/gitbook/src/api/encodeConfig.js @@ -0,0 +1,36 @@ +const objectPath = require('object-path'); +const deprecate = require('./deprecate'); + +/** + * Encode a config object into a JS config api + * + * @param {Output} output + * @param {Config} config + * @return {Object} + */ +function encodeConfig(output, config) { + const result = { + values: config.getValues().toJS(), + + get(key, defaultValue) { + return objectPath.get(result.values, key, defaultValue); + }, + + set(key, value) { + return objectPath.set(result.values, key, value); + } + }; + + deprecate.field(output, 'config.options', result, 'options', + result.values, '"config.options" property is deprecated, use "config.get(key)" instead'); + + deprecate.field(output, 'config.options.generator', result.values, 'generator', + output.getGenerator(), '"options.generator" property is deprecated, use "output.name" instead'); + + deprecate.field(output, 'config.options.generator', result.values, 'output', + output.getRoot(), '"options.output" property is deprecated, use "output.root()" instead'); + + return result; +} + +module.exports = encodeConfig; diff --git a/packages/gitbook/src/api/encodeGlobal.js b/packages/gitbook/src/api/encodeGlobal.js new file mode 100644 index 0000000..89db629 --- /dev/null +++ b/packages/gitbook/src/api/encodeGlobal.js @@ -0,0 +1,264 @@ +const path = require('path'); +const Promise = require('../utils/promise'); +const PathUtils = require('../utils/path'); +const fs = require('../utils/fs'); + +const Plugins = require('../plugins'); +const deprecate = require('./deprecate'); +const defaultBlocks = require('../constants/defaultBlocks'); +const gitbook = require('../gitbook'); +const parsers = require('../parsers'); + +const encodeConfig = require('./encodeConfig'); +const encodeSummary = require('./encodeSummary'); +const encodeNavigation = require('./encodeNavigation'); +const encodePage = require('./encodePage'); + +/** + * Encode a global context into a JS object + * It's the context for page's hook, etc + * + * @param {Output} output + * @return {Object} + */ +function encodeGlobal(output) { + const book = output.getBook(); + const bookFS = book.getContentFS(); + const logger = output.getLogger(); + const outputFolder = output.getRoot(); + const plugins = output.getPlugins(); + const blocks = Plugins.listBlocks(plugins); + const urls = output.getURLIndex(); + + const result = { + log: logger, + config: encodeConfig(output, book.getConfig()), + summary: encodeSummary(output, book.getSummary()), + + /** + * Return absolute path to the root folder of the book + * @return {String} + */ + root() { + return book.getRoot(); + }, + + /** + * Return absolute path to the root folder of the book (for content) + * @return {String} + */ + contentRoot() { + return book.getContentRoot(); + }, + + /** + * Check if the book is a multilingual book. + * @return {Boolean} + */ + isMultilingual() { + return book.isMultilingual(); + }, + + /** + * Check if the book is a language book for a multilingual book. + * @return {Boolean} + */ + isLanguageBook() { + return book.isLanguageBook(); + }, + + /** + * Read a file from the book. + * @param {String} fileName + * @return {Promise<Buffer>} + */ + readFile(fileName) { + return bookFS.read(fileName); + }, + + /** + * Read a file from the book as a string. + * @param {String} fileName + * @return {Promise<String>} + */ + readFileAsString(fileName) { + return bookFS.readAsString(fileName); + }, + + /** + * Resolve a file from the book root. + * @param {String} fileName + * @return {String} + */ + resolve(fileName) { + return path.resolve(book.getContentRoot(), fileName); + }, + + /** + * Resolve a page by it path. + * @param {String} filePath + * @return {String} + */ + getPageByPath(filePath) { + const page = output.getPage(filePath); + if (!page) return undefined; + + return encodePage(output, page); + }, + + /** + * Render a block of text (markdown/asciidoc). + * @param {String} type + * @param {String} text + * @return {Promise<String>} + */ + renderBlock(type, text) { + const parser = parsers.get(type); + + return parser.parsePage(text) + .get('content'); + }, + + /** + * Render an inline text (markdown/asciidoc). + * @param {String} type + * @param {String} text + * @return {Promise<String>} + */ + renderInline(type, text) { + const parser = parsers.get(type); + + return parser.parseInline(text) + .get('content'); + }, + + template: { + + /** + * Apply a templating block and returns its result. + * @param {String} name + * @param {Object} blockData + * @return {Promise|Object} + */ + applyBlock(name, blockData) { + const block = blocks.get(name) || defaultBlocks.get(name); + return Promise(block.applyBlock(blockData, result)); + } + }, + + output: { + + /** + * Name of the generator being used + * {String} + */ + name: output.getGenerator(), + + /** + * Return absolute path to the root folder of output + * @return {String} + */ + root() { + return outputFolder; + }, + + /** + * Resolve a file from the output root. + * @param {String} fileName + * @return {String} + */ + resolve(fileName) { + return path.resolve(outputFolder, fileName); + }, + + /** + * Convert a filepath into an url + * @return {String} + */ + toURL(filePath) { + return urls.resolveToURL(filePath); + }, + + /** + * Check that a file exists. + * @param {String} fileName + * @return {Promise} + */ + hasFile(fileName, content) { + return Promise() + .then(function() { + const filePath = PathUtils.resolveInRoot(outputFolder, fileName); + + return fs.exists(filePath); + }); + }, + + /** + * Write a file to the output folder, + * It creates the required folder + * + * @param {String} fileName + * @param {Buffer} content + * @return {Promise} + */ + writeFile(fileName, content) { + return Promise() + .then(function() { + const filePath = PathUtils.resolveInRoot(outputFolder, fileName); + + return fs.ensureFile(filePath) + .then(function() { + return fs.writeFile(filePath, content); + }); + }); + }, + + /** + * Copy a file to the output folder + * It creates the required folder. + * + * @param {String} inputFile + * @param {String} outputFile + * @param {Buffer} content + * @return {Promise} + */ + copyFile(inputFile, outputFile, content) { + return Promise() + .then(function() { + const outputFilePath = PathUtils.resolveInRoot(outputFolder, outputFile); + + return fs.ensureFile(outputFilePath) + .then(function() { + return fs.copy(inputFile, outputFilePath); + }); + }); + } + }, + + gitbook: { + version: gitbook.version + } + }; + + // Deprecated properties + + deprecate.renamedMethod(output, 'this.isSubBook', result, 'isSubBook', 'isLanguageBook'); + deprecate.renamedMethod(output, 'this.contentLink', result, 'contentLink', 'output.toURL'); + + deprecate.field(output, 'this.generator', result, 'generator', + output.getGenerator(), '"this.generator" property is deprecated, use "this.output.name" instead'); + + deprecate.field(output, 'this.navigation', result, 'navigation', function() { + return encodeNavigation(output); + }, '"navigation" property is deprecated'); + + deprecate.field(output, 'this.book', result, 'book', + result, '"book" property is deprecated, use "this" directly instead'); + + deprecate.field(output, 'this.options', result, 'options', + result.config.values, '"options" property is deprecated, use config.get(key) instead'); + + return result; +} + +module.exports = encodeGlobal; diff --git a/packages/gitbook/src/api/encodeNavigation.js b/packages/gitbook/src/api/encodeNavigation.js new file mode 100644 index 0000000..95ab8e3 --- /dev/null +++ b/packages/gitbook/src/api/encodeNavigation.js @@ -0,0 +1,64 @@ +const Immutable = require('immutable'); + +/** + * Encode an article for next/prev + * + * @param {Map<String:Page>} + * @param {Article} + * @return {Object} + */ +function encodeArticle(pages, article) { + const articlePath = article.getPath(); + + return { + path: articlePath, + title: article.getTitle(), + level: article.getLevel(), + exists: (articlePath && pages.has(articlePath)), + external: article.isExternal() + }; +} + +/** + * this.navigation is a deprecated property from GitBook v2 + * + * @param {Output} + * @return {Object} + */ +function encodeNavigation(output) { + const book = output.getBook(); + const pages = output.getPages(); + const summary = book.getSummary(); + const articles = summary.getArticlesAsList(); + + + const navigation = articles + .map(function(article, i) { + const ref = article.getRef(); + if (!ref) { + return undefined; + } + + const prev = articles.get(i - 1); + const next = articles.get(i + 1); + + return [ + ref, + { + index: i, + title: article.getTitle(), + introduction: (i === 0), + prev: prev ? encodeArticle(pages, prev) : undefined, + next: next ? encodeArticle(pages, next) : undefined, + level: article.getLevel() + } + ]; + }) + .filter(function(e) { + return Boolean(e); + }); + + return Immutable.Map(navigation).toJS(); +} + +module.exports = encodeNavigation; diff --git a/packages/gitbook/src/api/encodePage.js b/packages/gitbook/src/api/encodePage.js new file mode 100644 index 0000000..7d563cd --- /dev/null +++ b/packages/gitbook/src/api/encodePage.js @@ -0,0 +1,45 @@ +const JSONUtils = require('../json'); +const deprecate = require('./deprecate'); +const encodeProgress = require('./encodeProgress'); + +/** + * Encode a page in a context to a JS API + * + * @param {Output} output + * @param {Page} page + * @return {Object} + */ +function encodePage(output, page) { + const book = output.getBook(); + const urls = output.getURLIndex(); + const summary = book.getSummary(); + const fs = book.getContentFS(); + const file = page.getFile(); + + // JS Page is based on the JSON output + const result = JSONUtils.encodePage(page, summary, urls); + + result.type = file.getType(); + result.path = file.getPath(); + result.rawPath = fs.resolve(result.path); + + result.setAttribute = (key, value) => { + result.attributes[key] = value; + return result; + }; + + deprecate.field(output, 'page.progress', result, 'progress', function() { + return encodeProgress(output, page); + }, '"page.progress" property is deprecated'); + + deprecate.field(output, 'page.sections', result, 'sections', [ + { + content: result.content, + type: 'normal' + } + ], '"sections" property is deprecated, use page.content instead'); + + return result; +} + +module.exports = encodePage; diff --git a/packages/gitbook/src/api/encodeProgress.js b/packages/gitbook/src/api/encodeProgress.js new file mode 100644 index 0000000..3224370 --- /dev/null +++ b/packages/gitbook/src/api/encodeProgress.js @@ -0,0 +1,63 @@ +const Immutable = require('immutable'); +const encodeNavigation = require('./encodeNavigation'); + +/** + page.progress is a deprecated property from GitBook v2 + + @param {Output} + @param {Page} + @return {Object} +*/ +function encodeProgress(output, page) { + const current = page.getPath(); + let navigation = encodeNavigation(output); + navigation = Immutable.Map(navigation); + + const n = navigation.size; + let percent = 0, prevPercent = 0, currentChapter = null; + let done = true; + + const chapters = navigation + .map(function(nav, chapterPath) { + nav.path = chapterPath; + return nav; + }) + .valueSeq() + .sortBy(function(nav) { + return nav.index; + }) + .map(function(nav, i) { + // Calcul percent + nav.percent = (i * 100) / Math.max((n - 1), 1); + + // Is it done + nav.done = done; + if (nav.path == current) { + currentChapter = nav; + percent = nav.percent; + done = false; + } else if (done) { + prevPercent = nav.percent; + } + + return nav; + }) + .toJS(); + + return { + // Previous percent + prevPercent, + + // Current percent + percent, + + // List of chapter with progress + chapters, + + // Current chapter + current: currentChapter + }; +} + +module.exports = encodeProgress; + diff --git a/packages/gitbook/src/api/encodeSummary.js b/packages/gitbook/src/api/encodeSummary.js new file mode 100644 index 0000000..323f5d4 --- /dev/null +++ b/packages/gitbook/src/api/encodeSummary.js @@ -0,0 +1,52 @@ +const encodeSummaryArticle = require('../json/encodeSummaryArticle'); + +/** + Encode summary to provide an API to plugin + + @param {Output} output + @param {Config} config + @return {Object} +*/ +function encodeSummary(output, summary) { + const result = { + + /** + Iterate over the summary, it stops when the "iter" returns false + + @param {Function} iter + */ + walk(iter) { + summary.getArticle(function(article) { + const jsonArticle = encodeSummaryArticle(article, false); + + return iter(jsonArticle); + }); + }, + + /** + Get an article by its level + + @param {String} level + @return {Object} + */ + getArticleByLevel(level) { + const article = summary.getByLevel(level); + return (article ? encodeSummaryArticle(article) : undefined); + }, + + /** + Get an article by its path + + @param {String} level + @return {Object} + */ + getArticleByPath(level) { + const article = summary.getByPath(level); + return (article ? encodeSummaryArticle(article) : undefined); + } + }; + + return result; +} + +module.exports = encodeSummary; diff --git a/packages/gitbook/src/api/index.js b/packages/gitbook/src/api/index.js new file mode 100644 index 0000000..3956c62 --- /dev/null +++ b/packages/gitbook/src/api/index.js @@ -0,0 +1,7 @@ + +module.exports = { + encodePage: require('./encodePage'), + decodePage: require('./decodePage'), + encodeGlobal: require('./encodeGlobal'), + decodeGlobal: require('./decodeGlobal') +}; diff --git a/packages/gitbook/src/browser.js b/packages/gitbook/src/browser.js new file mode 100644 index 0000000..1e7fad2 --- /dev/null +++ b/packages/gitbook/src/browser.js @@ -0,0 +1,23 @@ +const Modifiers = require('./modifiers'); + +module.exports = { + Parse: require('./parse'), + // Models + Book: require('./models/book'), + FS: require('./models/fs'), + File: require('./models/file'), + Summary: require('./models/summary'), + Glossary: require('./models/glossary'), + Config: require('./models/config'), + Page: require('./models/page'), + PluginDependency: require('./models/pluginDependency'), + // Modifiers + SummaryModifier: Modifiers.Summary, + ConfigModifier: Modifiers.Config, + // Constants + CONFIG_FILES: require('./constants/configFiles.js'), + IGNORE_FILES: require('./constants/ignoreFiles.js'), + DEFAULT_PLUGINS: require('./constants/defaultPlugins'), + EXTENSIONS_MARKDOWN: require('./constants/extsMarkdown'), + EXTENSIONS_ASCIIDOC: require('./constants/extsAsciidoc') +}; diff --git a/packages/gitbook/src/browser/__tests__/render.js b/packages/gitbook/src/browser/__tests__/render.js new file mode 100644 index 0000000..799be44 --- /dev/null +++ b/packages/gitbook/src/browser/__tests__/render.js @@ -0,0 +1,4 @@ + +describe('render', () => { + +}); diff --git a/packages/gitbook/src/browser/loadPlugins.js b/packages/gitbook/src/browser/loadPlugins.js new file mode 100644 index 0000000..c9bf7a6 --- /dev/null +++ b/packages/gitbook/src/browser/loadPlugins.js @@ -0,0 +1,31 @@ +const path = require('path'); +const timing = require('../utils/timing'); + +/** + * Load all browser plugins. + * + * @param {OrderedMap<Plugin>} plugins + * @param {String} type ('browser', 'ebook') + * @return {Array} + */ +function loadPlugins(plugins, type) { + return timing.measure( + 'browser.loadPlugins', + () => { + return plugins + .valueSeq() + .filter(plugin => plugin.getPackage().has(type)) + .map(plugin => { + const browserFile = path.resolve( + plugin.getPath(), + plugin.getPackage().get(type) + ); + + return require(browserFile); + }) + .toArray(); + } + ); +} + +module.exports = loadPlugins; diff --git a/packages/gitbook/src/browser/render.js b/packages/gitbook/src/browser/render.js new file mode 100644 index 0000000..86c3dff --- /dev/null +++ b/packages/gitbook/src/browser/render.js @@ -0,0 +1,103 @@ +const ReactDOMServer = require('gitbook-core/lib/server'); +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const timing = require('../utils/timing'); +const loadPlugins = require('./loadPlugins'); + +function HTML({head, innerHTML, payload, scripts, bootstrap}) { + const attrs = head.htmlAttributes.toComponent(); + + return ( + <html {...attrs}> + <head> + {head.title.toComponent()} + {head.meta.toComponent()} + {head.link.toComponent()} + {head.style.toComponent()} + </head> + <body> + <div id="content" dangerouslySetInnerHTML={{__html: innerHTML}} /> + {scripts.map(script => { + return <script key={script} src={script} />; + })} + <script type="application/payload+json" dangerouslySetInnerHTML={{__html: payload}} /> + <script type="application/javascript" dangerouslySetInnerHTML={{__html: bootstrap}} /> + {head.script.toComponent()} + </body> + </html> + ); +} +HTML.propTypes = { + head: React.PropTypes.object, + innerHTML: React.PropTypes.string, + payload: React.PropTypes.string, + bootstrap: React.PropTypes.string, + scripts: React.PropTypes.arrayOf(React.PropTypes.string) +}; + +/** + * Get bootstrap code for a role + * @param {String} role + * @return {String} + */ +function getBootstrapCode(role) { + return `(function() { require("gitbook-core").bootstrap({ role: "${role}" }) })()`; +} + +/** + * Render a view using plugins. + * + * @param {OrderedMap<String:Plugin>} plugin + * @param {Object} initialState + * @param {String} type ("ebook" or "browser") + * @param {String} role + * @return {String} html + */ +function render(plugins, initialState, type, role) { + return timing.measure( + 'browser.render', + () => { + // Load the plugins + const browserPlugins = loadPlugins(plugins, type); + const payload = JSON.stringify(initialState); + const context = GitBook.createContext(browserPlugins, initialState); + + const currentFile = context.getState().file; + + const scripts = plugins.toList() + .filter(plugin => plugin.getPackage().has(type)) + .map(plugin => { + return currentFile.relative('gitbook/plugins/' + plugin.getName() + '.js'); + }) + .toArray(); + + const el = GitBook.renderWithContext(context, { role }); + + // We're done with the context + context.deactivate(); + + // Render inner body + const innerHTML = ReactDOMServer.renderToString(el); + + // Get headers + const head = GitBook.Head.rewind(); + + // Render whole HTML page + const htmlEl = <HTML + head={head} + innerHTML={innerHTML} + payload={payload} + bootstrap={getBootstrapCode(role)} + scripts={[ + currentFile.relative('gitbook/core.js') + ].concat(scripts)} + />; + + const html = ReactDOMServer.renderToStaticMarkup(htmlEl); + return html; + } + ); +} + +module.exports = render; diff --git a/packages/gitbook/src/cli/build.js b/packages/gitbook/src/cli/build.js new file mode 100644 index 0000000..3f5c937 --- /dev/null +++ b/packages/gitbook/src/cli/build.js @@ -0,0 +1,34 @@ +const Parse = require('../parse'); +const Output = require('../output'); +const timing = require('../utils/timing'); + +const options = require('./options'); +const getBook = require('./getBook'); +const getOutputFolder = require('./getOutputFolder'); + + +module.exports = { + name: 'build [book] [output]', + description: 'build a book', + options: [ + options.log, + options.format, + options.timing + ], + exec(args, kwargs) { + const book = getBook(args, kwargs); + const outputFolder = getOutputFolder(args); + + const Generator = Output.getGenerator(kwargs.format); + + return Parse.parseBook(book) + .then(function(resultBook) { + return Output.generate(Generator, resultBook, { + root: outputFolder + }); + }) + .fin(function() { + if (kwargs.timing) timing.dump(book.getLogger()); + }); + } +}; diff --git a/packages/gitbook/src/cli/buildEbook.js b/packages/gitbook/src/cli/buildEbook.js new file mode 100644 index 0000000..56e63f8 --- /dev/null +++ b/packages/gitbook/src/cli/buildEbook.js @@ -0,0 +1,78 @@ +const path = require('path'); +const tmp = require('tmp'); + +const Promise = require('../utils/promise'); +const fs = require('../utils/fs'); +const Parse = require('../parse'); +const Output = require('../output'); + +const options = require('./options'); +const getBook = require('./getBook'); + + +module.exports = function(format) { + return { + name: (format + ' [book] [output]'), + description: 'build a book into an ebook file', + options: [ + options.log + ], + exec(args, kwargs) { + const extension = '.' + format; + + // Output file will be stored in + const outputFile = args[1] || ('book' + extension); + + // Create temporary directory + const outputFolder = tmp.dirSync().name; + + const book = getBook(args, kwargs); + const logger = book.getLogger(); + const Generator = Output.getGenerator('ebook'); + + return Parse.parseBook(book) + .then(function(resultBook) { + return Output.generate(Generator, resultBook, { + root: outputFolder, + format + }); + }) + + // Extract ebook file + .then(function(output) { + const book = output.getBook(); + const languages = book.getLanguages(); + + if (book.isMultilingual()) { + return Promise.forEach(languages.getList(), function(lang) { + const langID = lang.getID(); + + const langOutputFile = path.join( + path.dirname(outputFile), + path.basename(outputFile, extension) + '_' + langID + extension + ); + + return fs.copy( + path.resolve(outputFolder, langID, 'index' + extension), + langOutputFile + ); + }) + .thenResolve(languages.getCount()); + } else { + return fs.copy( + path.resolve(outputFolder, 'index' + extension), + outputFile + ).thenResolve(1); + } + }) + + // Log end + .then(function(count) { + logger.info.ok(count + ' file(s) generated'); + + logger.debug('cleaning up... '); + return logger.debug.promise(fs.rmDir(outputFolder)); + }); + } + }; +}; diff --git a/packages/gitbook/src/cli/getBook.js b/packages/gitbook/src/cli/getBook.js new file mode 100644 index 0000000..b37e49c --- /dev/null +++ b/packages/gitbook/src/cli/getBook.js @@ -0,0 +1,23 @@ +const path = require('path'); +const Book = require('../models/book'); +const createNodeFS = require('../fs/node'); + +/** + Return a book instance to work on from + command line args/kwargs + + @param {Array} args + @param {Object} kwargs + @return {Book} +*/ +function getBook(args, kwargs) { + const input = path.resolve(args[0] || process.cwd()); + const logLevel = kwargs.log; + + const fs = createNodeFS(input); + const book = Book.createForFS(fs); + + return book.setLogLevel(logLevel); +} + +module.exports = getBook; diff --git a/packages/gitbook/src/cli/getOutputFolder.js b/packages/gitbook/src/cli/getOutputFolder.js new file mode 100644 index 0000000..94f22da --- /dev/null +++ b/packages/gitbook/src/cli/getOutputFolder.js @@ -0,0 +1,17 @@ +const path = require('path'); + +/** + Return path to output folder + + @param {Array} args + @return {String} +*/ +function getOutputFolder(args) { + const bookRoot = path.resolve(args[0] || process.cwd()); + const defaultOutputRoot = path.join(bookRoot, '_book'); + const outputFolder = args[1] ? path.resolve(process.cwd(), args[1]) : defaultOutputRoot; + + return outputFolder; +} + +module.exports = getOutputFolder; diff --git a/packages/gitbook/src/cli/index.js b/packages/gitbook/src/cli/index.js new file mode 100644 index 0000000..48ad117 --- /dev/null +++ b/packages/gitbook/src/cli/index.js @@ -0,0 +1,12 @@ +const buildEbook = require('./buildEbook'); + +module.exports = [ + require('./build'), + require('./serve'), + require('./install'), + require('./parse'), + require('./init'), + buildEbook('pdf'), + buildEbook('epub'), + buildEbook('mobi') +]; diff --git a/packages/gitbook/src/cli/init.js b/packages/gitbook/src/cli/init.js new file mode 100644 index 0000000..51d6869 --- /dev/null +++ b/packages/gitbook/src/cli/init.js @@ -0,0 +1,17 @@ +const path = require('path'); + +const options = require('./options'); +const initBook = require('../init'); + +module.exports = { + name: 'init [book]', + description: 'setup and create files for chapters', + options: [ + options.log + ], + exec(args, kwargs) { + const bookRoot = path.resolve(process.cwd(), args[0] || './'); + + return initBook(bookRoot); + } +}; diff --git a/packages/gitbook/src/cli/install.js b/packages/gitbook/src/cli/install.js new file mode 100644 index 0000000..6af4013 --- /dev/null +++ b/packages/gitbook/src/cli/install.js @@ -0,0 +1,21 @@ +const options = require('./options'); +const getBook = require('./getBook'); + +const Parse = require('../parse'); +const Plugins = require('../plugins'); + +module.exports = { + name: 'install [book]', + description: 'install all plugins dependencies', + options: [ + options.log + ], + exec(args, kwargs) { + const book = getBook(args, kwargs); + + return Parse.parseConfig(book) + .then(function(resultBook) { + return Plugins.installPlugins(resultBook); + }); + } +}; diff --git a/packages/gitbook/src/cli/options.js b/packages/gitbook/src/cli/options.js new file mode 100644 index 0000000..d643f91 --- /dev/null +++ b/packages/gitbook/src/cli/options.js @@ -0,0 +1,31 @@ +const Logger = require('../utils/logger'); + +const logOptions = { + name: 'log', + description: 'Minimum log level to display', + values: Logger.LEVELS + .keySeq() + .map(function(s) { + return s.toLowerCase(); + }).toJS(), + defaults: 'info' +}; + +const formatOption = { + name: 'format', + description: 'Format to build to', + values: ['website', 'json', 'ebook'], + defaults: 'website' +}; + +const timingOption = { + name: 'timing', + description: 'Print timing debug information', + defaults: false +}; + +module.exports = { + log: logOptions, + format: formatOption, + timing: timingOption +}; diff --git a/packages/gitbook/src/cli/parse.js b/packages/gitbook/src/cli/parse.js new file mode 100644 index 0000000..3d38fe7 --- /dev/null +++ b/packages/gitbook/src/cli/parse.js @@ -0,0 +1,79 @@ +const options = require('./options'); +const getBook = require('./getBook'); + +const Parse = require('../parse'); + +function printBook(book) { + const logger = book.getLogger(); + + const config = book.getConfig(); + const configFile = config.getFile(); + + const summary = book.getSummary(); + const summaryFile = summary.getFile(); + + const readme = book.getReadme(); + const readmeFile = readme.getFile(); + + const glossary = book.getGlossary(); + const glossaryFile = glossary.getFile(); + + if (configFile.exists()) { + logger.info.ln('Configuration file is', configFile.getPath()); + } + + if (readmeFile.exists()) { + logger.info.ln('Introduction file is', readmeFile.getPath()); + } + + if (glossaryFile.exists()) { + logger.info.ln('Glossary file is', glossaryFile.getPath()); + } + + if (summaryFile.exists()) { + logger.info.ln('Table of Contents file is', summaryFile.getPath()); + } +} + +function printMultingualBook(book) { + const logger = book.getLogger(); + const languages = book.getLanguages(); + const books = book.getBooks(); + + logger.info.ln(languages.size + ' languages'); + + languages.forEach(function(lang) { + logger.info.ln('Language:', lang.getTitle()); + printBook(books.get(lang.getID())); + logger.info.ln(''); + }); +} + +module.exports = { + name: 'parse [book]', + description: 'parse and print debug information about a book', + options: [ + options.log + ], + exec(args, kwargs) { + const book = getBook(args, kwargs); + const logger = book.getLogger(); + + return Parse.parseBook(book) + .then(function(resultBook) { + const rootFolder = book.getRoot(); + const contentFolder = book.getContentRoot(); + + logger.info.ln('Book located in:', rootFolder); + if (contentFolder != rootFolder) { + logger.info.ln('Content located in:', contentFolder); + } + + if (resultBook.isMultilingual()) { + printMultingualBook(resultBook); + } else { + printBook(resultBook); + } + }); + } +}; diff --git a/packages/gitbook/src/cli/serve.js b/packages/gitbook/src/cli/serve.js new file mode 100644 index 0000000..6397c2e --- /dev/null +++ b/packages/gitbook/src/cli/serve.js @@ -0,0 +1,159 @@ +/* eslint-disable no-console */ + +const tinylr = require('tiny-lr'); +const open = require('open'); + +const Parse = require('../parse'); +const Output = require('../output'); +const ConfigModifier = require('../modifiers').Config; + +const Promise = require('../utils/promise'); + +const options = require('./options'); +const getBook = require('./getBook'); +const getOutputFolder = require('./getOutputFolder'); +const Server = require('./server'); +const watch = require('./watch'); + +let server, lrServer, lrPath; + +function waitForCtrlC() { + const d = Promise.defer(); + + process.on('SIGINT', function() { + d.resolve(); + }); + + return d.promise; +} + + +function generateBook(args, kwargs) { + const port = kwargs.port; + const outputFolder = getOutputFolder(args); + const book = getBook(args, kwargs); + const Generator = Output.getGenerator(kwargs.format); + const browser = kwargs['browser']; + + const hasWatch = kwargs['watch']; + const hasLiveReloading = kwargs['live']; + const hasOpen = kwargs['open']; + + // Stop server if running + if (server.isRunning()) console.log('Stopping server'); + + return server.stop() + .then(function() { + return Parse.parseBook(book) + .then(function(resultBook) { + if (hasLiveReloading) { + // Enable livereload plugin + let config = resultBook.getConfig(); + config = ConfigModifier.addPlugin(config, 'livereload'); + resultBook = resultBook.set('config', config); + } + + return Output.generate(Generator, resultBook, { + root: outputFolder + }); + }); + }) + .then(function() { + console.log(); + console.log('Starting server ...'); + return server.start(outputFolder, port); + }) + .then(function() { + console.log('Serving book on http://localhost:' + port); + + if (lrPath && hasLiveReloading) { + // trigger livereload + lrServer.changed({ + body: { + files: [lrPath] + } + }); + } + + if (hasOpen) { + open('http://localhost:' + port, browser); + } + }) + .then(function() { + if (!hasWatch) { + return waitForCtrlC(); + } + + return watch(book.getRoot()) + .then(function(filepath) { + // set livereload path + lrPath = filepath; + console.log('Restart after change in file', filepath); + console.log(''); + return generateBook(args, kwargs); + }); + }); +} + +module.exports = { + name: 'serve [book] [output]', + description: 'serve the book as a website for testing', + options: [ + { + name: 'port', + description: 'Port for server to listen on', + defaults: 4000 + }, + { + name: 'lrport', + description: 'Port for livereload server to listen on', + defaults: 35729 + }, + { + name: 'watch', + description: 'Enable file watcher and live reloading', + defaults: true + }, + { + name: 'live', + description: 'Enable live reloading', + defaults: true + }, + { + name: 'open', + description: 'Enable opening book in browser', + defaults: false + }, + { + name: 'browser', + description: 'Specify browser for opening book', + defaults: '' + }, + options.log, + options.format + ], + exec(args, kwargs) { + server = new Server(); + const hasWatch = kwargs['watch']; + const hasLiveReloading = kwargs['live']; + + return Promise() + .then(function() { + if (!hasWatch || !hasLiveReloading) { + return; + } + + lrServer = tinylr({}); + return Promise.nfcall(lrServer.listen.bind(lrServer), kwargs.lrport) + .then(function() { + console.log('Live reload server started on port:', kwargs.lrport); + console.log('Press CTRL+C to quit ...'); + console.log(''); + + }); + }) + .then(function() { + return generateBook(args, kwargs); + }); + } +}; diff --git a/packages/gitbook/src/cli/server.js b/packages/gitbook/src/cli/server.js new file mode 100644 index 0000000..c494efc --- /dev/null +++ b/packages/gitbook/src/cli/server.js @@ -0,0 +1,127 @@ +const events = require('events'); +const http = require('http'); +const send = require('send'); +const url = require('url'); + +const Promise = require('../utils/promise'); + +class Server extends events.EventEmitter { + constructor() { + super(); + this.running = null; + this.dir = null; + this.port = 0; + this.sockets = []; + } + + /** + * Return true if the server is running + * @return {Boolean} + */ + isRunning() { + return !!this.running; + } + + /** + * Stop the server + * @return {Promise} + */ + stop() { + const that = this; + if (!this.isRunning()) return Promise(); + + const d = Promise.defer(); + this.running.close(function(err) { + that.running = null; + that.emit('state', false); + + if (err) d.reject(err); + else d.resolve(); + }); + + for (let i = 0; i < this.sockets.length; i++) { + this.sockets[i].destroy(); + } + + return d.promise; + } + + /** + * Start the server + * @return {Promise} + */ + start(dir, port) { + const that = this; + let pre = Promise(); + port = port || 8004; + + if (that.isRunning()) pre = this.stop(); + return pre + .then(function() { + const d = Promise.defer(); + + that.running = http.createServer(function(req, res) { + // Render error + function error(err) { + res.statusCode = err.status || 500; + res.end(err.message); + } + + // Redirect to directory's index.html + function redirect() { + const resultURL = urlTransform(req.url, function(parsed) { + parsed.pathname += '/'; + return parsed; + }); + + res.statusCode = 301; + res.setHeader('Location', resultURL); + res.end('Redirecting to ' + resultURL); + } + + res.setHeader('X-Current-Location', req.url); + + // Send file + send(req, url.parse(req.url).pathname, { + root: dir + }) + .on('error', error) + .on('directory', redirect) + .pipe(res); + }); + + that.running.on('connection', function(socket) { + that.sockets.push(socket); + socket.setTimeout(4000); + socket.on('close', function() { + that.sockets.splice(that.sockets.indexOf(socket), 1); + }); + }); + + that.running.listen(port, function(err) { + if (err) return d.reject(err); + + that.port = port; + that.dir = dir; + that.emit('state', true); + d.resolve(); + }); + + return d.promise; + }); + } +} + +/** + * urlTransform is a helper function that allows a function to transform + * a url string in it's parsed form and returns the new url as a string + * + * @param {String} uri + * @param {Function} fn + * @return {String} + */ +function urlTransform(uri, fn) { + return url.format(fn(url.parse(uri))); +} + +module.exports = Server; diff --git a/packages/gitbook/src/cli/watch.js b/packages/gitbook/src/cli/watch.js new file mode 100644 index 0000000..e1d453c --- /dev/null +++ b/packages/gitbook/src/cli/watch.js @@ -0,0 +1,46 @@ +const path = require('path'); +const chokidar = require('chokidar'); + +const Promise = require('../utils/promise'); +const parsers = require('../parsers'); + +/** + Watch a folder and resolve promise once a file is modified + + @param {String} dir + @return {Promise} +*/ +function watch(dir) { + const d = Promise.defer(); + dir = path.resolve(dir); + + const toWatch = [ + 'book.json', 'book.js', '_layouts/**' + ]; + + // Watch all parsable files + parsers.extensions.forEach(function(ext) { + toWatch.push('**/*' + ext); + }); + + const watcher = chokidar.watch(toWatch, { + cwd: dir, + ignored: '_book/**', + ignoreInitial: true + }); + + watcher.once('all', function(e, filepath) { + watcher.close(); + + d.resolve(filepath); + }); + watcher.once('error', function(err) { + watcher.close(); + + d.reject(err); + }); + + return d.promise; +} + +module.exports = watch; diff --git a/packages/gitbook/src/constants/__tests__/configSchema.js b/packages/gitbook/src/constants/__tests__/configSchema.js new file mode 100644 index 0000000..df83680 --- /dev/null +++ b/packages/gitbook/src/constants/__tests__/configSchema.js @@ -0,0 +1,46 @@ +const jsonschema = require('jsonschema'); +const schema = require('../configSchema'); + +describe('configSchema', function() { + + function validate(cfg) { + const v = new jsonschema.Validator(); + return v.validate(cfg, schema, { + propertyName: 'config' + }); + } + + describe('structure', function() { + + it('should accept dot in filename', function() { + const result = validate({ + structure: { + readme: 'book-intro.adoc' + } + }); + + expect(result.errors.length).toBe(0); + }); + + it('should accept uppercase in filename', function() { + const result = validate({ + structure: { + readme: 'BOOK.adoc' + } + }); + + expect(result.errors.length).toBe(0); + }); + + it('should not accept filepath', function() { + const result = validate({ + structure: { + readme: 'folder/myFile.md' + } + }); + + expect(result.errors.length).toBe(1); + }); + + }); +}); diff --git a/packages/gitbook/src/constants/configDefault.js b/packages/gitbook/src/constants/configDefault.js new file mode 100644 index 0000000..c384c6c --- /dev/null +++ b/packages/gitbook/src/constants/configDefault.js @@ -0,0 +1,6 @@ +const Immutable = require('immutable'); +const jsonSchemaDefaults = require('json-schema-defaults'); + +const schema = require('./configSchema'); + +module.exports = Immutable.fromJS(jsonSchemaDefaults(schema)); diff --git a/packages/gitbook/src/constants/configFiles.js b/packages/gitbook/src/constants/configFiles.js new file mode 100644 index 0000000..a67fd74 --- /dev/null +++ b/packages/gitbook/src/constants/configFiles.js @@ -0,0 +1,5 @@ +// Configuration files to test (sorted) +module.exports = [ + 'book.js', + 'book.json' +]; diff --git a/packages/gitbook/src/constants/configSchema.js b/packages/gitbook/src/constants/configSchema.js new file mode 100644 index 0000000..9aaf8cd --- /dev/null +++ b/packages/gitbook/src/constants/configSchema.js @@ -0,0 +1,194 @@ +const FILENAME_REGEX = '^[a-zA-Z-._\d,\s]+$'; + +module.exports = { + '$schema': 'http://json-schema.org/schema#', + 'id': 'https://gitbook.com/schemas/book.json', + 'title': 'GitBook Configuration', + 'type': 'object', + 'properties': { + 'root': { + 'type': 'string', + 'title': 'Path fro the root folder containing the book\'s content' + }, + 'title': { + 'type': 'string', + 'title': 'Title of the book, default is extracted from README' + }, + 'isbn': { + 'type': 'string', + 'title': 'ISBN for published book' + }, + 'language': { + 'type': 'string', + 'title': 'Language of the book' + }, + 'author': { + 'type': 'string', + 'title': 'Name of the author' + }, + 'gitbook': { + 'type': 'string', + 'default': '*', + 'title': 'GitBook version to match' + }, + 'direction': { + 'type': 'string', + 'enum': ['ltr', 'rtl'], + 'title': 'Direction of texts, default is detected in the pages' + }, + 'theme': { + 'type': 'string', + 'default': 'default', + 'title': 'Name of the theme plugin to use' + }, + 'variables': { + 'type': 'object', + 'title': 'Templating context variables' + }, + 'plugins': { + 'oneOf': [ + { '$ref': '#/definitions/pluginsArray' }, + { '$ref': '#/definitions/pluginsString' } + ], + 'default': [] + }, + 'pluginsConfig': { + 'type': 'object', + 'title': 'Configuration for plugins' + }, + 'structure': { + 'type': 'object', + 'properties': { + 'langs': { + 'default': 'LANGS.md', + 'type': 'string', + 'title': 'File to use as languages index', + 'pattern': FILENAME_REGEX + }, + 'readme': { + 'default': 'README.md', + 'type': 'string', + 'title': 'File to use as preface', + 'pattern': FILENAME_REGEX + }, + 'glossary': { + 'default': 'GLOSSARY.md', + 'type': 'string', + 'title': 'File to use as glossary index', + 'pattern': FILENAME_REGEX + }, + 'summary': { + 'default': 'SUMMARY.md', + 'type': 'string', + 'title': 'File to use as table of contents', + 'pattern': FILENAME_REGEX + } + }, + 'additionalProperties': false + }, + 'pdf': { + 'type': 'object', + 'title': 'PDF specific configurations', + 'properties': { + 'pageNumbers': { + 'type': 'boolean', + 'default': true, + 'title': 'Add page numbers to the bottom of every page' + }, + 'fontSize': { + 'type': 'integer', + 'minimum': 8, + 'maximum': 30, + 'default': 12, + 'title': 'Font size for the PDF output' + }, + 'fontFamily': { + 'type': 'string', + 'default': 'Arial', + 'title': 'Font family for the PDF output' + }, + 'paperSize': { + 'type': 'string', + 'enum': ['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'legal', 'letter'], + 'default': 'a4', + 'title': 'Paper size for the PDF' + }, + 'chapterMark': { + 'type': 'string', + 'enum': ['pagebreak', 'rule', 'both', 'none'], + 'default': 'pagebreak', + 'title': 'How to mark detected chapters' + }, + 'pageBreaksBefore': { + 'type': 'string', + 'default': '/', + 'title': 'An XPath expression. Page breaks are inserted before the specified elements. To disable use the expression: "/"' + }, + 'margin': { + 'type': 'object', + 'properties': { + 'right': { + 'type': 'integer', + 'title': 'Right Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 62 + }, + 'left': { + 'type': 'integer', + 'title': 'Left Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 62 + }, + 'top': { + 'type': 'integer', + 'title': 'Top Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 56 + }, + 'bottom': { + 'type': 'integer', + 'title': 'Bottom Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 56 + } + } + } + } + } + }, + 'required': [], + 'definitions': { + 'pluginsArray': { + 'type': 'array', + 'items': { + 'oneOf': [ + { '$ref': '#/definitions/pluginObject' }, + { '$ref': '#/definitions/pluginString' } + ] + } + }, + 'pluginsString': { + 'type': 'string' + }, + 'pluginString': { + 'type': 'string' + }, + 'pluginObject': { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string' + }, + 'version': { + 'type': 'string' + } + }, + 'additionalProperties': false, + 'required': ['name'] + } + } +}; diff --git a/packages/gitbook/src/constants/defaultBlocks.js b/packages/gitbook/src/constants/defaultBlocks.js new file mode 100644 index 0000000..d1fe6ff --- /dev/null +++ b/packages/gitbook/src/constants/defaultBlocks.js @@ -0,0 +1,5 @@ +const Immutable = require('immutable'); + +module.exports = Immutable.Map({ + +}); diff --git a/packages/gitbook/src/constants/defaultFilters.js b/packages/gitbook/src/constants/defaultFilters.js new file mode 100644 index 0000000..c9bffe1 --- /dev/null +++ b/packages/gitbook/src/constants/defaultFilters.js @@ -0,0 +1,15 @@ +const Immutable = require('immutable'); +const moment = require('moment'); + +module.exports = Immutable.Map({ + // Format a date + // ex: 'MMMM Do YYYY, h:mm:ss a + date(time, format) { + return moment(time).format(format); + }, + + // Relative Time + dateFromNow(time) { + return moment(time).fromNow(); + } +}); diff --git a/packages/gitbook/src/constants/defaultPlugins.js b/packages/gitbook/src/constants/defaultPlugins.js new file mode 100644 index 0000000..326ad3a --- /dev/null +++ b/packages/gitbook/src/constants/defaultPlugins.js @@ -0,0 +1,31 @@ +const Immutable = require('immutable'); +const PluginDependency = require('../models/pluginDependency'); + +const pkg = require('../../package.json'); + +/** + * Create a PluginDependency from a dependency of gitbook + * @param {String} pluginName + * @return {PluginDependency} + */ +function createFromDependency(pluginName) { + const npmID = PluginDependency.nameToNpmID(pluginName); + const version = pkg.dependencies[npmID]; + + return PluginDependency.create(pluginName, version); +} + +/* + * List of default plugins for all books, + * default plugins should be installed in node dependencies of GitBook + */ +module.exports = Immutable.List([ + 'highlight', + 'search', + 'lunr', + 'sharing', + 'hints', + 'headings', + 'copy-code', + 'theme-default' +]).map(createFromDependency); diff --git a/packages/gitbook/src/constants/extsAsciidoc.js b/packages/gitbook/src/constants/extsAsciidoc.js new file mode 100644 index 0000000..b2f4ce4 --- /dev/null +++ b/packages/gitbook/src/constants/extsAsciidoc.js @@ -0,0 +1,4 @@ +module.exports = [ + '.adoc', + '.asciidoc' +]; diff --git a/packages/gitbook/src/constants/extsMarkdown.js b/packages/gitbook/src/constants/extsMarkdown.js new file mode 100644 index 0000000..44bf36b --- /dev/null +++ b/packages/gitbook/src/constants/extsMarkdown.js @@ -0,0 +1,5 @@ +module.exports = [ + '.md', + '.markdown', + '.mdown' +]; diff --git a/packages/gitbook/src/constants/ignoreFiles.js b/packages/gitbook/src/constants/ignoreFiles.js new file mode 100644 index 0000000..aac225e --- /dev/null +++ b/packages/gitbook/src/constants/ignoreFiles.js @@ -0,0 +1,6 @@ +// Files containing ignore pattner (sorted by priority) +module.exports = [ + '.ignore', + '.gitignore', + '.bookignore' +]; diff --git a/packages/gitbook/src/constants/pluginAssetsFolder.js b/packages/gitbook/src/constants/pluginAssetsFolder.js new file mode 100644 index 0000000..cd44722 --- /dev/null +++ b/packages/gitbook/src/constants/pluginAssetsFolder.js @@ -0,0 +1,2 @@ + +module.exports = '_assets'; diff --git a/packages/gitbook/src/constants/pluginHooks.js b/packages/gitbook/src/constants/pluginHooks.js new file mode 100644 index 0000000..2d5dcaa --- /dev/null +++ b/packages/gitbook/src/constants/pluginHooks.js @@ -0,0 +1,8 @@ +module.exports = [ + 'init', + 'finish', + 'finish:before', + 'config', + 'page', + 'page:before' +]; diff --git a/packages/gitbook/src/constants/pluginPrefix.js b/packages/gitbook/src/constants/pluginPrefix.js new file mode 100644 index 0000000..c7f2dd0 --- /dev/null +++ b/packages/gitbook/src/constants/pluginPrefix.js @@ -0,0 +1,5 @@ + +/* + All GitBook plugins are NPM packages starting with this prefix. +*/ +module.exports = 'gitbook-plugin-'; diff --git a/packages/gitbook/src/constants/pluginResources.js b/packages/gitbook/src/constants/pluginResources.js new file mode 100644 index 0000000..cc9d134 --- /dev/null +++ b/packages/gitbook/src/constants/pluginResources.js @@ -0,0 +1,6 @@ +const Immutable = require('immutable'); + +module.exports = Immutable.List([ + 'js', + 'css' +]); diff --git a/packages/gitbook/src/constants/templatesFolder.js b/packages/gitbook/src/constants/templatesFolder.js new file mode 100644 index 0000000..aad6a72 --- /dev/null +++ b/packages/gitbook/src/constants/templatesFolder.js @@ -0,0 +1,2 @@ + +module.exports = '_layouts'; diff --git a/packages/gitbook/src/constants/themePrefix.js b/packages/gitbook/src/constants/themePrefix.js new file mode 100644 index 0000000..621e85c --- /dev/null +++ b/packages/gitbook/src/constants/themePrefix.js @@ -0,0 +1,4 @@ +/* + All GitBook themes plugins name start with this prefix once shorted. +*/ +module.exports = 'theme-'; diff --git a/packages/gitbook/src/fs/__tests__/mock.js b/packages/gitbook/src/fs/__tests__/mock.js new file mode 100644 index 0000000..7d1ea48 --- /dev/null +++ b/packages/gitbook/src/fs/__tests__/mock.js @@ -0,0 +1,81 @@ +const createMockFS = require('../mock'); + +describe('MockFS', function() { + const fs = createMockFS({ + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary', + 'folder': { + 'test.md': 'Cool', + 'folder2': { + 'hello.md': 'Hello', + 'world.md': 'World' + } + } + }); + + describe('exists', function() { + it('must return true for a file', function() { + return fs.exists('README.md') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + it('must return false for a non existing file', function() { + return fs.exists('README_NOTEXISTS.md') + .then(function(result) { + expect(result).toBeFalsy(); + }); + }); + + it('must return true for a directory', function() { + return fs.exists('folder') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + it('must return true for a deep file', function() { + return fs.exists('folder/test.md') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + it('must return true for a deep file (2)', function() { + return fs.exists('folder/folder2/hello.md') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + }); + + describe('readAsString', function() { + it('must return content for a file', function() { + return fs.readAsString('README.md') + .then(function(result) { + expect(result).toBe('Hello World'); + }); + }); + + it('must return content for a deep file', function() { + return fs.readAsString('folder/test.md') + .then(function(result) { + expect(result).toBe('Cool'); + }); + }); + }); + + describe('readDir', function() { + it('must return content for a directory', function() { + return fs.readDir('./') + .then(function(files) { + expect(files.size).toBe(3); + expect(files.includes('README.md')).toBeTruthy(); + expect(files.includes('SUMMARY.md')).toBeTruthy(); + expect(files.includes('folder/')).toBeTruthy(); + }); + }); + }); +}); + diff --git a/packages/gitbook/src/fs/mock.js b/packages/gitbook/src/fs/mock.js new file mode 100644 index 0000000..611b2ab --- /dev/null +++ b/packages/gitbook/src/fs/mock.js @@ -0,0 +1,95 @@ +const path = require('path'); +const is = require('is'); +const Buffer = require('buffer').Buffer; +const Immutable = require('immutable'); + +const FS = require('../models/fs'); +const error = require('../utils/error'); + +/** + * Create a fake filesystem for unit testing GitBook. + * @param {Map<String:String|Map>} + * @return {FS} + */ +function createMockFS(files, root = '') { + files = Immutable.fromJS(files); + const mtime = new Date(); + + function getFile(filePath) { + const parts = path.normalize(filePath).split(path.sep); + return parts.reduce(function(list, part, i) { + if (!list) return null; + + let file; + + if (!part || part === '.') file = list; + else file = list.get(part); + + if (!file) return null; + + if (is.string(file)) { + if (i === (parts.length - 1)) return file; + else return null; + } + + return file; + }, files); + } + + function fsExists(filePath) { + return Boolean(getFile(filePath) !== null); + } + + function fsReadFile(filePath) { + const file = getFile(filePath); + if (!is.string(file)) { + throw error.FileNotFoundError({ + filename: filePath + }); + } + + return new Buffer(file, 'utf8'); + } + + function fsStatFile(filePath) { + const file = getFile(filePath); + if (!file) { + throw error.FileNotFoundError({ + filename: filePath + }); + } + + return { + mtime + }; + } + + function fsReadDir(filePath) { + const dir = getFile(filePath); + if (!dir || is.string(dir)) { + throw error.FileNotFoundError({ + filename: filePath + }); + } + + return dir + .map(function(content, name) { + if (!is.string(content)) { + name = name + '/'; + } + + return name; + }) + .valueSeq(); + } + + return FS.create({ + root: '', + fsExists, + fsReadFile, + fsStatFile, + fsReadDir + }); +} + +module.exports = createMockFS; diff --git a/packages/gitbook/src/fs/node.js b/packages/gitbook/src/fs/node.js new file mode 100644 index 0000000..6e28daf --- /dev/null +++ b/packages/gitbook/src/fs/node.js @@ -0,0 +1,42 @@ +const path = require('path'); +const Immutable = require('immutable'); +const fresh = require('fresh-require'); + +const fs = require('../utils/fs'); +const FS = require('../models/fs'); + +function fsReadDir(folder) { + return fs.readdir(folder) + .then(function(files) { + files = Immutable.List(files); + + return files + .map(function(file) { + if (file == '.' || file == '..') return; + + const stat = fs.statSync(path.join(folder, file)); + if (stat.isDirectory()) file = file + path.sep; + return file; + }) + .filter(function(file) { + return Boolean(file); + }); + }); +} + +function fsLoadObject(filename) { + return fresh(filename, require); +} + +module.exports = function createNodeFS(root) { + return FS.create({ + root, + + fsExists: fs.exists, + fsReadFile: fs.readFile, + fsStatFile: fs.stat, + fsReadDir, + fsLoadObject, + fsReadAsStream: fs.readStream + }); +}; diff --git a/packages/gitbook/src/gitbook.js b/packages/gitbook/src/gitbook.js new file mode 100644 index 0000000..5786e68 --- /dev/null +++ b/packages/gitbook/src/gitbook.js @@ -0,0 +1,28 @@ +const semver = require('semver'); +const pkg = require('../package.json'); + +const VERSION = pkg.version; +const VERSION_STABLE = VERSION.replace(/\-(\S+)/g, ''); + +const START_TIME = new Date(); + +/** + Verify that this gitbook version satisfies a requirement + We can't directly use samver.satisfies since it will break all plugins when gitbook version is a prerelease (beta, alpha) + + @param {String} condition + @return {Boolean} +*/ +function satisfies(condition) { + // Test with real version + if (semver.satisfies(VERSION, condition)) return true; + + // Test with future stable release + return semver.satisfies(VERSION_STABLE, condition); +} + +module.exports = { + version: pkg.version, + satisfies, + START_TIME +}; diff --git a/packages/gitbook/src/index.js b/packages/gitbook/src/index.js new file mode 100644 index 0000000..fc8f254 --- /dev/null +++ b/packages/gitbook/src/index.js @@ -0,0 +1,9 @@ +const common = require('./browser'); + +module.exports = { + ...common, + initBook: require('./init'), + createNodeFS: require('./fs/node'), + Output: require('./output'), + commands: require('./cli') +}; diff --git a/packages/gitbook/src/init.js b/packages/gitbook/src/init.js new file mode 100644 index 0000000..bbd5f90 --- /dev/null +++ b/packages/gitbook/src/init.js @@ -0,0 +1,83 @@ +const path = require('path'); + +const createNodeFS = require('./fs/node'); +const fs = require('./utils/fs'); +const Promise = require('./utils/promise'); +const File = require('./models/file'); +const Readme = require('./models/readme'); +const Book = require('./models/book'); +const Parse = require('./parse'); + +/** + Initialize folder structure for a book + Read SUMMARY to created the right chapter + + @param {Book} + @param {String} + @return {Promise} +*/ +function initBook(rootFolder) { + const extension = '.md'; + + return fs.mkdirp(rootFolder) + + // Parse the summary and readme + .then(function() { + const bookFS = createNodeFS(rootFolder); + const book = Book.createForFS(bookFS); + + return Parse.parseReadme(book) + + // Setup default readme if doesn't found one + .fail(function() { + const readmeFile = File.createWithFilepath('README' + extension); + const readme = Readme.create(readmeFile); + return book.setReadme(readme); + }); + }) + .then(Parse.parseSummary) + + .then(function(book) { + const logger = book.getLogger(); + const summary = book.getSummary(); + const summaryFile = summary.getFile(); + const summaryFilename = summaryFile.getPath() || ('SUMMARY' + extension); + + const articles = summary.getArticlesAsList(); + + // Write pages + return Promise.forEach(articles, function(article) { + const articlePath = article.getPath(); + const filePath = articlePath ? path.join(rootFolder, articlePath) : null; + if (!filePath) { + return; + } + + return fs.assertFile(filePath, function() { + return fs.ensureFile(filePath) + .then(function() { + logger.info.ln('create', article.getPath()); + return fs.writeFile(filePath, '# ' + article.getTitle() + '\n\n'); + }); + }); + }) + + // Write summary + .then(function() { + const filePath = path.join(rootFolder, summaryFilename); + + return fs.ensureFile(filePath) + .then(function() { + logger.info.ln('create ' + path.basename(filePath)); + return fs.writeFile(filePath, summary.toText(extension)); + }); + }) + + // Log end + .then(function() { + logger.info.ln('initialization is finished'); + }); + }); +} + +module.exports = initBook; diff --git a/packages/gitbook/src/json/encodeFile.js b/packages/gitbook/src/json/encodeFile.js new file mode 100644 index 0000000..2295ac1 --- /dev/null +++ b/packages/gitbook/src/json/encodeFile.js @@ -0,0 +1,23 @@ + +/** + * Return a JSON representation of a file + * + * @param {File} file + * @param {URIIndex} urls + * @return {JSON} json + */ +function encodeFileToJson(file, urls) { + const filePath = file.getPath(); + if (!filePath) { + return undefined; + } + + return { + path: filePath, + mtime: file.getMTime(), + type: file.getType(), + url: urls.resolveToURL(filePath) + }; +} + +module.exports = encodeFileToJson; diff --git a/packages/gitbook/src/json/encodeGlossary.js b/packages/gitbook/src/json/encodeGlossary.js new file mode 100644 index 0000000..d82bb62 --- /dev/null +++ b/packages/gitbook/src/json/encodeGlossary.js @@ -0,0 +1,22 @@ +const encodeFile = require('./encodeFile'); +const encodeGlossaryEntry = require('./encodeGlossaryEntry'); + +/** + * Encode a glossary to JSON + * + * @param {Glossary} glossary + * @param {URIIndex} urls + * @return {JSON} json + */ +function encodeGlossary(glossary, urls) { + const file = glossary.getFile(); + const entries = glossary.getEntries(); + + return { + file: encodeFile(file, urls), + entries: entries + .map(encodeGlossaryEntry).toJS() + }; +} + +module.exports = encodeGlossary; diff --git a/packages/gitbook/src/json/encodeGlossaryEntry.js b/packages/gitbook/src/json/encodeGlossaryEntry.js new file mode 100644 index 0000000..52e13c3 --- /dev/null +++ b/packages/gitbook/src/json/encodeGlossaryEntry.js @@ -0,0 +1,16 @@ + +/** + * Encode a SummaryArticle to JSON + * + * @param {GlossaryEntry} entry + * @return {JSON} json + */ +function encodeGlossaryEntry(entry) { + return { + id: entry.getID(), + name: entry.getName(), + description: entry.getDescription() + }; +} + +module.exports = encodeGlossaryEntry; diff --git a/packages/gitbook/src/json/encodeLanguages.js b/packages/gitbook/src/json/encodeLanguages.js new file mode 100644 index 0000000..809cfb2 --- /dev/null +++ b/packages/gitbook/src/json/encodeLanguages.js @@ -0,0 +1,29 @@ +const encodeFile = require('./encodeFile'); + +/** + * Encode a languages listing to JSON + * + * @param {Languages} languages + * @param {String} currentLanguage + * @param {URIIndex} urls + * @return {JSON} json +*/ +function encodeLanguages(languages, currentLanguage, urls) { + const file = languages.getFile(); + const list = languages.getList(); + + return { + file: encodeFile(file, urls), + current: currentLanguage, + list: list + .valueSeq() + .map(function(lang) { + return { + id: lang.getID(), + title: lang.getTitle() + }; + }).toJS() + }; +} + +module.exports = encodeLanguages; diff --git a/packages/gitbook/src/json/encodePage.js b/packages/gitbook/src/json/encodePage.js new file mode 100644 index 0000000..0671721 --- /dev/null +++ b/packages/gitbook/src/json/encodePage.js @@ -0,0 +1,41 @@ +const encodeSummaryArticle = require('./encodeSummaryArticle'); + +/** + * Return a JSON representation of a page. + * + * @param {Page} page + * @param {Summary} summary + * @param {URIIndex} urls + * @return {JSON} json + */ +function encodePage(page, summary, urls) { + const file = page.getFile(); + const attributes = page.getAttributes(); + const article = summary.getByPath(file.getPath()); + + const result = { + content: page.getContent(), + dir: page.getDir(), + attributes: attributes.toJS() + }; + + if (article) { + result.title = article.getTitle(); + result.level = article.getLevel(); + result.depth = article.getDepth(); + + const nextArticle = summary.getNextArticle(article); + if (nextArticle) { + result.next = encodeSummaryArticle(nextArticle, urls, false); + } + + const prevArticle = summary.getPrevArticle(article); + if (prevArticle) { + result.previous = encodeSummaryArticle(prevArticle, urls, false); + } + } + + return result; +} + +module.exports = encodePage; diff --git a/packages/gitbook/src/json/encodeReadme.js b/packages/gitbook/src/json/encodeReadme.js new file mode 100644 index 0000000..dff81cf --- /dev/null +++ b/packages/gitbook/src/json/encodeReadme.js @@ -0,0 +1,18 @@ +const encodeFile = require('./encodeFile'); + +/** + * Encode a readme to JSON. + * + * @param {Readme} readme + * @param {URIIndex} urls + * @return {JSON} json + */ +function encodeReadme(readme, urls) { + const file = readme.getFile(); + + return { + file: encodeFile(file, urls) + }; +} + +module.exports = encodeReadme; diff --git a/packages/gitbook/src/json/encodeState.js b/packages/gitbook/src/json/encodeState.js new file mode 100644 index 0000000..faac972 --- /dev/null +++ b/packages/gitbook/src/json/encodeState.js @@ -0,0 +1,42 @@ +const gitbook = require('../gitbook'); +const encodeSummary = require('./encodeSummary'); +const encodeGlossary = require('./encodeGlossary'); +const encodeReadme = require('./encodeReadme'); +const encodeLanguages = require('./encodeLanguages'); +const encodePage = require('./encodePage'); +const encodeFile = require('./encodeFile'); + +/** + * Encode context to JSON from an output instance. + * This JSON representation is used as initial state for the redux store. + * + * @param {Output} output + * @param {Page} page? + * @return {JSON} + */ +function encodeStateToJSON(output, page) { + const book = output.getBook(); + const urls = output.getURLIndex(); + + return { + output: { + name: output.getGenerator() + }, + gitbook: { + version: gitbook.version, + time: gitbook.START_TIME + }, + + summary: encodeSummary(book.getSummary(), urls), + glossary: encodeGlossary(book.getGlossary(), urls), + readme: encodeReadme(book.getReadme(), urls), + config: book.getConfig().getValues().toJS(), + languages: book.isMultilingual() ? + encodeLanguages(book.getLanguages(), book.getLanguage(), urls) : undefined, + + page: page ? encodePage(page, book.getSummary(), urls) : undefined, + file: page ? encodeFile(page.getFile(), urls) : undefined + }; +} + +module.exports = encodeStateToJSON; diff --git a/packages/gitbook/src/json/encodeSummary.js b/packages/gitbook/src/json/encodeSummary.js new file mode 100644 index 0000000..8380379 --- /dev/null +++ b/packages/gitbook/src/json/encodeSummary.js @@ -0,0 +1,23 @@ +const encodeFile = require('./encodeFile'); +const encodeSummaryPart = require('./encodeSummaryPart'); + +/** + * Encode a summary to JSON + * + * @param {Summary} summary + * @param {URIIndex} urls + * @return {Object} + */ +function encodeSummary(summary, urls) { + const file = summary.getFile(); + const parts = summary.getParts(); + + return { + file: encodeFile(file, urls), + parts: parts + .map(part => encodeSummaryPart(part, urls)) + .toJS() + }; +} + +module.exports = encodeSummary; diff --git a/packages/gitbook/src/json/encodeSummaryArticle.js b/packages/gitbook/src/json/encodeSummaryArticle.js new file mode 100644 index 0000000..0fb6368 --- /dev/null +++ b/packages/gitbook/src/json/encodeSummaryArticle.js @@ -0,0 +1,30 @@ + +/** + * Encode a SummaryArticle to JSON + * + * @param {SummaryArticle} article + * @param {URIIndex} urls + * @param {Boolean} recursive + * @return {Object} + */ +function encodeSummaryArticle(article, urls, recursive) { + let articles = undefined; + if (recursive !== false) { + articles = article.getArticles() + .map(innerArticle => encodeSummaryArticle(innerArticle, urls, recursive)) + .toJS(); + } + + return { + title: article.getTitle(), + level: article.getLevel(), + depth: article.getDepth(), + anchor: article.getAnchor(), + url: urls.resolveToURL(article.getPath() || article.getUrl()), + path: article.getPath(), + ref: article.getRef(), + articles + }; +} + +module.exports = encodeSummaryArticle; diff --git a/packages/gitbook/src/json/encodeSummaryPart.js b/packages/gitbook/src/json/encodeSummaryPart.js new file mode 100644 index 0000000..fbcdc4c --- /dev/null +++ b/packages/gitbook/src/json/encodeSummaryPart.js @@ -0,0 +1,19 @@ +const encodeSummaryArticle = require('./encodeSummaryArticle'); + +/** + * Encode a SummaryPart to JSON. + * + * @param {SummaryPart} part + * @param {URIIndex} urls + * @return {JSON} json + */ +function encodeSummaryPart(part, urls) { + return { + title: part.getTitle(), + articles: part.getArticles() + .map(article => encodeSummaryArticle(article, urls)) + .toJS() + }; +} + +module.exports = encodeSummaryPart; diff --git a/packages/gitbook/src/json/index.js b/packages/gitbook/src/json/index.js new file mode 100644 index 0000000..49ab195 --- /dev/null +++ b/packages/gitbook/src/json/index.js @@ -0,0 +1,10 @@ + +module.exports = { + encodeState: require('./encodeState'), + encodeFile: require('./encodeFile'), + encodePage: require('./encodePage'), + encodeSummary: require('./encodeSummary'), + encodeSummaryArticle: require('./encodeSummaryArticle'), + encodeReadme: require('./encodeReadme'), + encodeLanguages: require('./encodeLanguages') +}; diff --git a/packages/gitbook/src/models/__tests__/config.js b/packages/gitbook/src/models/__tests__/config.js new file mode 100644 index 0000000..a865f96 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/config.js @@ -0,0 +1,89 @@ +const Immutable = require('immutable'); +const Config = require('../config'); + +describe('Config', function() { + const config = Config.createWithValues({ + hello: { + world: 1, + test: 'Hello', + isFalse: false + } + }); + + describe('getValue', function() { + it('must return value as immutable', function() { + const value = config.getValue('hello'); + expect(Immutable.Map.isMap(value)).toBeTruthy(); + }); + + it('must return deep value', function() { + const value = config.getValue('hello.world'); + expect(value).toBe(1); + }); + + it('must return default value if non existant', function() { + const value = config.getValue('hello.nonExistant', 'defaultValue'); + expect(value).toBe('defaultValue'); + }); + + it('must not return default value for falsy values', function() { + const value = config.getValue('hello.isFalse', 'defaultValue'); + expect(value).toBe(false); + }); + }); + + describe('setValue', function() { + it('must set value as immutable', function() { + const testConfig = config.setValue('hello', { + 'cool': 1 + }); + const value = testConfig.getValue('hello'); + + expect(Immutable.Map.isMap(value)).toBeTruthy(); + expect(value.size).toBe(1); + expect(value.has('cool')).toBeTruthy(); + }); + + it('must set deep value', function() { + const testConfig = config.setValue('hello.world', 2); + const hello = testConfig.getValue('hello'); + const world = testConfig.getValue('hello.world'); + + expect(Immutable.Map.isMap(hello)).toBeTruthy(); + expect(hello.size).toBe(3); + + expect(world).toBe(2); + }); + }); + + describe('toReducedVersion', function() { + it('must only return diffs for simple values', function() { + const _config = Config.createWithValues({ + gitbook: '3.0.0' + }); + + const reducedVersion = _config.toReducedVersion(); + + expect(reducedVersion.toJS()).toEqual({ + gitbook: '3.0.0' + }); + }); + + it('must only return diffs for deep values', function() { + const _config = Config.createWithValues({ + structure: { + readme: 'intro.md' + } + }); + + const reducedVersion = _config.toReducedVersion(); + + expect(reducedVersion.toJS()).toEqual({ + structure: { + readme: 'intro.md' + } + }); + }); + }); +}); + diff --git a/packages/gitbook/src/models/__tests__/glossary.js b/packages/gitbook/src/models/__tests__/glossary.js new file mode 100644 index 0000000..b50338a --- /dev/null +++ b/packages/gitbook/src/models/__tests__/glossary.js @@ -0,0 +1,39 @@ +const File = require('../file'); +const Glossary = require('../glossary'); +const GlossaryEntry = require('../glossaryEntry'); + +describe('Glossary', function() { + const glossary = Glossary.createFromEntries(File(), [ + { + name: 'Hello World', + description: 'Awesome!' + }, + { + name: 'JavaScript', + description: 'This is a cool language' + } + ]); + + describe('createFromEntries', function() { + it('must add all entries', function() { + const entries = glossary.getEntries(); + expect(entries.size).toBe(2); + }); + + it('must add entries as GlossaryEntries', function() { + const entries = glossary.getEntries(); + const entry = entries.get('hello-world'); + expect(entry instanceof GlossaryEntry).toBeTruthy(); + }); + }); + + describe('toText', function() { + it('return as markdown', function() { + return glossary.toText('.md') + .then(function(text) { + expect(text).toContain('# Glossary'); + }); + }); + }); +}); + diff --git a/packages/gitbook/src/models/__tests__/glossaryEntry.js b/packages/gitbook/src/models/__tests__/glossaryEntry.js new file mode 100644 index 0000000..66ddab4 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/glossaryEntry.js @@ -0,0 +1,14 @@ +const GlossaryEntry = require('../glossaryEntry'); + +describe('GlossaryEntry', function() { + describe('getID', function() { + it('must return a normalized ID', function() { + const entry = new GlossaryEntry({ + name: 'Hello World' + }); + + expect(entry.getID()).toBe('hello-world'); + }); + }); +}); + diff --git a/packages/gitbook/src/models/__tests__/page.js b/packages/gitbook/src/models/__tests__/page.js new file mode 100644 index 0000000..b004121 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/page.js @@ -0,0 +1,26 @@ +const Immutable = require('immutable'); +const Page = require('../page'); + +describe('Page', function() { + + describe('toText', function() { + it('must not prepend frontmatter if no attributes', function() { + const page = (new Page()).merge({ + content: 'Hello World' + }); + + expect(page.toText()).toBe('Hello World'); + }); + + it('must prepend frontmatter if attributes', function() { + const page = (new Page()).merge({ + content: 'Hello World', + attributes: Immutable.fromJS({ + hello: 'world' + }) + }); + + expect(page.toText()).toBe('---\nhello: world\n---\n\nHello World'); + }); + }); +}); diff --git a/packages/gitbook/src/models/__tests__/plugin.js b/packages/gitbook/src/models/__tests__/plugin.js new file mode 100644 index 0000000..63cb58c --- /dev/null +++ b/packages/gitbook/src/models/__tests__/plugin.js @@ -0,0 +1,26 @@ +describe('Plugin', function() { + const Plugin = require('../plugin'); + + describe('createFromString', function() { + it('must parse name', function() { + const plugin = Plugin.createFromString('hello'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('*'); + }); + + it('must parse version', function() { + const plugin = Plugin.createFromString('hello@1.0.0'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('1.0.0'); + }); + }); + + describe('isLoaded', function() { + it('must return false for empty plugin', function() { + const plugin = Plugin.createFromString('hello'); + expect(plugin.isLoaded()).toBe(false); + }); + + }); +}); + diff --git a/packages/gitbook/src/models/__tests__/pluginDependency.js b/packages/gitbook/src/models/__tests__/pluginDependency.js new file mode 100644 index 0000000..cda0cc2 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/pluginDependency.js @@ -0,0 +1,80 @@ +const Immutable = require('immutable'); +const PluginDependency = require('../pluginDependency'); + +describe('PluginDependency', function() { + describe('createFromString', function() { + it('must parse name', function() { + const plugin = PluginDependency.createFromString('hello'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('*'); + }); + + it('must parse state', function() { + const plugin = PluginDependency.createFromString('-hello'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.isEnabled()).toBe(false); + }); + + describe('Version', function() { + it('must parse version', function() { + const plugin = PluginDependency.createFromString('hello@1.0.0'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('1.0.0'); + }); + + it('must parse semver', function() { + const plugin = PluginDependency.createFromString('hello@>=4.0.0'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('>=4.0.0'); + }); + }); + + describe('GIT Version', function() { + it('must handle HTTPS urls', function() { + const plugin = PluginDependency.createFromString('hello@git+https://github.com/GitbookIO/plugin-ga.git'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('git+https://github.com/GitbookIO/plugin-ga.git'); + }); + + it('must handle SSH urls', function() { + const plugin = PluginDependency.createFromString('hello@git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + }); + }); + + describe('listToArray', function() { + it('must create an array from a list of plugin dependencies', function() { + const list = PluginDependency.listToArray(Immutable.List([ + PluginDependency.createFromString('hello@1.0.0'), + PluginDependency.createFromString('noversion'), + PluginDependency.createFromString('-disabled') + ])); + + expect(list).toEqual([ + 'hello@1.0.0', + 'noversion', + '-disabled' + ]); + }); + }); + + describe('listFromArray', function() { + it('must create an array from a list of plugin dependencies', function() { + const arr = Immutable.fromJS([ + 'hello@1.0.0', + { + 'name': 'plugin-ga', + 'version': 'git+ssh://samy@github.com/GitbookIO/plugin-ga.git' + } + ]); + const list = PluginDependency.listFromArray(arr); + + expect(list.first().getName()).toBe('hello'); + expect(list.first().getVersion()).toBe('1.0.0'); + expect(list.last().getName()).toBe('plugin-ga'); + expect(list.last().getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + }); + }); + }); +}); diff --git a/packages/gitbook/src/models/__tests__/summary.js b/packages/gitbook/src/models/__tests__/summary.js new file mode 100644 index 0000000..49ed9b1 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/summary.js @@ -0,0 +1,93 @@ + +describe('Summary', function() { + const File = require('../file'); + const Summary = require('../summary'); + + const summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: 'My First Article', + ref: 'README.md' + }, + { + title: 'My Second Article', + ref: 'article.md' + }, + { + title: 'Article without ref' + }, + { + title: 'Article with absolute ref', + ref: 'https://google.fr' + } + ] + }, + { + title: 'Test' + } + ]); + + describe('createFromEntries', function() { + it('must add all parts', function() { + const parts = summary.getParts(); + expect(parts.size).toBe(2); + }); + }); + + describe('getByLevel', function() { + it('can return a Part', function() { + const part = summary.getByLevel('1'); + + expect(part).toBeDefined(); + expect(part.getArticles().size).toBe(4); + }); + + it('can return a Part (2)', function() { + const part = summary.getByLevel('2'); + + expect(part).toBeDefined(); + expect(part.getTitle()).toBe('Test'); + expect(part.getArticles().size).toBe(0); + }); + + it('can return an Article', function() { + const article = summary.getByLevel('1.1'); + + expect(article).toBeDefined(); + expect(article.getTitle()).toBe('My First Article'); + }); + }); + + describe('getByPath', function() { + it('return correct article', function() { + const article = summary.getByPath('README.md'); + + expect(article).toBeDefined(); + expect(article.getTitle()).toBe('My First Article'); + }); + + it('return correct article', function() { + const article = summary.getByPath('article.md'); + + expect(article).toBeDefined(); + expect(article.getTitle()).toBe('My Second Article'); + }); + + it('return undefined if not found', function() { + const article = summary.getByPath('NOT_EXISTING.md'); + + expect(article).toBeFalsy(); + }); + }); + + describe('toText', function() { + it('return as markdown', function() { + return summary.toText('.md') + .then(function(text) { + expect(text).toContain('# Summary'); + }); + }); + }); +}); + diff --git a/packages/gitbook/src/models/__tests__/summaryArticle.js b/packages/gitbook/src/models/__tests__/summaryArticle.js new file mode 100644 index 0000000..506d481 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/summaryArticle.js @@ -0,0 +1,52 @@ +const SummaryArticle = require('../summaryArticle'); +const File = require('../file'); + +describe('SummaryArticle', function() { + describe('createChildLevel', function() { + it('must create the right level', function() { + const article = SummaryArticle.create({}, '1.1'); + expect(article.createChildLevel()).toBe('1.1.1'); + }); + + it('must create the right level when has articles', function() { + const article = SummaryArticle.create({ + articles: [ + { + title: 'Test' + } + ] + }, '1.1'); + expect(article.createChildLevel()).toBe('1.1.2'); + }); + }); + + describe('isFile', function() { + it('must return true when exactly the file', function() { + const article = SummaryArticle.create({ + ref: 'hello.md' + }, '1.1'); + const file = File.createWithFilepath('hello.md'); + + expect(article.isFile(file)).toBe(true); + }); + + it('must return true when path is not normalized', function() { + const article = SummaryArticle.create({ + ref: '/hello.md' + }, '1.1'); + const file = File.createWithFilepath('hello.md'); + + expect(article.isFile(file)).toBe(true); + }); + + it('must return false when has anchor', function() { + const article = SummaryArticle.create({ + ref: 'hello.md#world' + }, '1.1'); + const file = File.createWithFilepath('hello.md'); + + expect(article.isFile(file)).toBe(false); + }); + }); +}); + diff --git a/packages/gitbook/src/models/__tests__/summaryPart.js b/packages/gitbook/src/models/__tests__/summaryPart.js new file mode 100644 index 0000000..fc9e8b5 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/summaryPart.js @@ -0,0 +1,22 @@ +const SummaryPart = require('../summaryPart'); + +describe('SummaryPart', function() { + describe('createChildLevel', function() { + it('must create the right level', function() { + const article = SummaryPart.create({}, '1'); + expect(article.createChildLevel()).toBe('1.1'); + }); + + it('must create the right level when has articles', function() { + const article = SummaryPart.create({ + articles: [ + { + title: 'Test' + } + ] + }, '1'); + expect(article.createChildLevel()).toBe('1.2'); + }); + }); +}); + diff --git a/packages/gitbook/src/models/__tests__/templateBlock.js b/packages/gitbook/src/models/__tests__/templateBlock.js new file mode 100644 index 0000000..5db8a80 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/templateBlock.js @@ -0,0 +1,218 @@ +const nunjucks = require('nunjucks'); +const Immutable = require('immutable'); +const Promise = require('../../utils/promise'); + +describe('TemplateBlock', function() { + const TemplateBlock = require('../templateBlock'); + + describe('.create', function() { + it('must initialize a simple TemplateBlock from a function', function() { + const templateBlock = TemplateBlock.create('sayhello', function(block) { + return { message: 'Hello World' }; + }); + + expect(templateBlock.getName()).toBe('sayhello'); + expect(templateBlock.getEndTag()).toBe('endsayhello'); + expect(templateBlock.getBlocks().size).toBe(0); + expect(templateBlock.getExtensionName()).toBe('BlocksayhelloExtension'); + }); + }); + + describe('.toProps', function() { + it('must handle sync method', function() { + const templateBlock = TemplateBlock.create('sayhello', function(block) { + return { message: 'Hello World' }; + }); + + return templateBlock.toProps() + .then(function(props) { + expect(props).toEqual({ message: 'Hello World' }); + }); + }); + + it('must not fail if return a string', function() { + const templateBlock = TemplateBlock.create('sayhello', function(block) { + return 'Hello World'; + }); + + return templateBlock.toProps() + .then(function(props) { + expect(props).toEqual({ children: 'Hello World' }); + }); + }); + }); + + describe('.getShortcuts', function() { + it('must return undefined if no shortcuts', function() { + const templateBlock = TemplateBlock.create('sayhello', function(block) { + return { message: 'Hello World' }; + }); + + expect(templateBlock.getShortcuts()).toNotExist(); + }); + + it('.must return complete shortcut', function() { + const templateBlock = TemplateBlock.create('sayhello', { + process(block) { + return { message: 'Hello World' }; + }, + shortcuts: { + parsers: ['markdown'], + start: '$', + end: '-' + } + }); + + const shortcut = templateBlock.getShortcuts(); + + expect(shortcut).toBeDefined(); + expect(shortcut.getStart()).toEqual('$'); + expect(shortcut.getEnd()).toEqual('-'); + expect(shortcut.getStartTag()).toEqual('sayhello'); + expect(shortcut.getEndTag()).toEqual('endsayhello'); + }); + }); + + describe('.toNunjucksExt()', function() { + it('should render children correctly', function() { + const templateBlock = TemplateBlock.create('sayhello', function(block) { + return 'Hello'; + }); + + // Create a fresh Nunjucks environment + const env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + const Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block + const src = '{% sayhello %}{% endsayhello %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('<xblock name="sayhello" props="{}">Hello</xblock>'); + }); + }); + + it('must handle HTML children', function() { + const templateBlock = TemplateBlock.create('sayhello', function(block) { + return '<p>Hello, World!</p>'; + }); + + // Create a fresh Nunjucks environment + const env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + const Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block + const src = '{% sayhello %}{% endsayhello %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('<xblock name="sayhello" props="{}"><p>Hello, World!</p></xblock>'); + }); + }); + + it('must inline props without children', function() { + const templateBlock = TemplateBlock.create('sayhello', function(block) { + return { + message: block.kwargs.tag + ' ' + block.kwargs.name + }; + }); + + // Create a fresh Nunjucks environment + const env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + const Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block + const src = '{% sayhello name="Samy", tag="p" %}{% endsayhello %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('<xblock name="sayhello" props="{"message":"p Samy"}"></xblock>'); + }); + }); + + it('must accept an async function', function() { + const templateBlock = TemplateBlock.create('sayhello', function(block) { + return Promise() + .delay(1) + .then(function() { + return { + children: 'Hello ' + block.children + }; + }); + }); + + // Create a fresh Nunjucks environment + const env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + const Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block + const src = '{% sayhello %}Samy{% endsayhello %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('<xblock name="sayhello" props="{}">Hello Samy</xblock>'); + }); + }); + + it('must handle nested blocks', function() { + const templateBlock = new TemplateBlock({ + name: 'yoda', + blocks: Immutable.List(['start', 'end']), + process(block) { + const nested = {}; + + block.blocks.forEach(function(blk) { + nested[blk.name] = blk.children.trim(); + }); + + return '<p class="yoda">' + nested.end + ' ' + nested.start + '</p>'; + } + }); + + // Create a fresh Nunjucks environment + const env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + const Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block + const src = '{% yoda %}{% start %}this sentence should be{% end %}inverted{% endyoda %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('<xblock name="yoda" props="{}"><p class="yoda">inverted this sentence should be</p></xblock>'); + }); + }); + + it('must handle multiple inline blocks', function() { + const templateBlock = new TemplateBlock({ + name: 'math', + process(block) { + return '<math>' + block.children + '</math>'; + } + }); + + // Create a fresh Nunjucks environment + const env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + const Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block after replacing shortcuts + const src = 'There should be two inline blocks as a result: {% math %}a = b{% endmath %} and {% math %}c = d{% endmath %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('There should be two inline blocks as a result: <xblock name="math" props="{}"><math>a = b</math></xblock> and <xblock name="math" props="{}"><math>c = d</math></xblock>'); + }); + }); + }); +}); diff --git a/packages/gitbook/src/models/__tests__/templateEngine.js b/packages/gitbook/src/models/__tests__/templateEngine.js new file mode 100644 index 0000000..30cd543 --- /dev/null +++ b/packages/gitbook/src/models/__tests__/templateEngine.js @@ -0,0 +1,51 @@ + +describe('TemplateBlock', function() { + const TemplateEngine = require('../templateEngine'); + + describe('create', function() { + it('must initialize with a list of filters', function() { + const engine = TemplateEngine.create({ + filters: { + hello(name) { + return 'Hello ' + name + '!'; + } + } + }); + const env = engine.toNunjucks(); + const res = env.renderString('{{ "Luke"|hello }}'); + + expect(res).toBe('Hello Luke!'); + }); + + it('must initialize with a list of globals', function() { + const engine = TemplateEngine.create({ + globals: { + hello(name) { + return 'Hello ' + name + '!'; + } + } + }); + const env = engine.toNunjucks(); + const res = env.renderString('{{ hello("Luke") }}'); + + expect(res).toBe('Hello Luke!'); + }); + + it('must pass context to filters and blocks', function() { + const engine = TemplateEngine.create({ + filters: { + hello(name) { + return 'Hello ' + name + ' ' + this.lastName + '!'; + } + }, + context: { + lastName: 'Skywalker' + } + }); + const env = engine.toNunjucks(); + const res = env.renderString('{{ "Luke"|hello }}'); + + expect(res).toBe('Hello Luke Skywalker!'); + }); + }); +}); diff --git a/packages/gitbook/src/models/__tests__/uriIndex.js b/packages/gitbook/src/models/__tests__/uriIndex.js new file mode 100644 index 0000000..f3be40b --- /dev/null +++ b/packages/gitbook/src/models/__tests__/uriIndex.js @@ -0,0 +1,84 @@ +const URIIndex = require('../uriIndex'); + +describe('URIIndex', () => { + let index; + + before(() => { + index = new URIIndex({ + 'README.md': 'index.html', + 'world.md': 'world.html', + 'hello/README.md': 'hello/index.html', + 'hello/test.md': 'hello/test.html' + }); + }); + + describe('.resolve', () => { + it('should resolve a basic file path', () => { + expect(index.resolve('README.md')).toBe('index.html'); + }); + + it('should resolve a nested file path', () => { + expect(index.resolve('hello/test.md')).toBe('hello/test.html'); + }); + + it('should normalize path', () => { + expect(index.resolve('./hello//test.md')).toBe('hello/test.html'); + }); + + it('should not fail for non existing entries', () => { + expect(index.resolve('notfound.md')).toBe('notfound.md'); + }); + + it('should not fail for absolute url', () => { + expect(index.resolve('http://google.fr')).toBe('http://google.fr'); + }); + + it('should preserve hash', () => { + expect(index.resolve('hello/test.md#myhash')).toBe('hello/test.html#myhash'); + }); + }); + + describe('.resolveToURL', () => { + it('should resolve a basic file path with directory index', () => { + expect(index.resolveToURL('README.md')).toBe('./'); + }); + + it('should resolve a basic file path with directory index', () => { + expect(index.resolveToURL('hello/README.md')).toBe('hello/'); + }); + }); + + describe('.resolveFrom', () => { + it('should resolve correctly in same directory', () => { + expect(index.resolveFrom('README.md', 'world.md')).toBe('world.html'); + }); + + it('should resolve correctly for a nested path', () => { + expect(index.resolveFrom('README.md', 'hello/README.md')).toBe('hello/index.html'); + }); + + it('should resolve correctly for a nested path (2)', () => { + expect(index.resolveFrom('hello/README.md', 'test.md')).toBe('test.html'); + }); + + it('should resolve correctly for a nested path (3)', () => { + expect(index.resolveFrom('hello/README.md', '../README.md')).toBe('../index.html'); + }); + + it('should preserve hash', () => { + expect(index.resolveFrom('README.md', 'hello/README.md#myhash')).toBe('hello/index.html#myhash'); + }); + + it('should not fail for absolute url', () => { + expect(index.resolveFrom('README.md', 'http://google.fr')).toBe('http://google.fr'); + }); + }); + + describe('.append', () => { + it('should normalize the filename', () => { + const newIndex = index.append('append//sometest.md', 'append/sometest.html'); + expect(newIndex.resolve('append/sometest.md')).toBe('append/sometest.html'); + }); + }); + +}); diff --git a/packages/gitbook/src/models/book.js b/packages/gitbook/src/models/book.js new file mode 100644 index 0000000..4668154 --- /dev/null +++ b/packages/gitbook/src/models/book.js @@ -0,0 +1,357 @@ +const path = require('path'); +const { Record, OrderedMap } = require('immutable'); + +const Logger = require('../utils/logger'); + +const FS = require('./fs'); +const Config = require('./config'); +const Readme = require('./readme'); +const Summary = require('./summary'); +const Glossary = require('./glossary'); +const Languages = require('./languages'); +const Ignore = require('./ignore'); + +const DEFAULTS = { + // Logger for output message + logger: new Logger(), + // Filesystem binded to the book scope to read files/directories + fs: new FS(), + // Ignore files parser + ignore: new Ignore(), + // Structure files + config: new Config(), + readme: new Readme(), + summary: new Summary(), + glossary: new Glossary(), + languages: new Languages(), + // ID of the language for language books + language: String(), + // List of children, if multilingual (String -> Book) + books: new OrderedMap() +}; + +class Book extends Record(DEFAULTS) { + getLogger() { + return this.get('logger'); + } + + getFS() { + return this.get('fs'); + } + + getIgnore() { + return this.get('ignore'); + } + + getConfig() { + return this.get('config'); + } + + getReadme() { + return this.get('readme'); + } + + getSummary() { + return this.get('summary'); + } + + getGlossary() { + return this.get('glossary'); + } + + getLanguages() { + return this.get('languages'); + } + + getBooks() { + return this.get('books'); + } + + getLanguage() { + return this.get('language'); + } + + /** + * Return FS instance to access the content + * @return {FS} + */ + getContentFS() { + const fs = this.getFS(); + const config = this.getConfig(); + const rootFolder = config.getValue('root'); + + if (rootFolder) { + return FS.reduceScope(fs, rootFolder); + } + + return fs; + } + + /** + * Return root of the book + * + * @return {String} + */ + getRoot() { + const fs = this.getFS(); + return fs.getRoot(); + } + + /** + * Return root for content of the book + * + * @return {String} + */ + getContentRoot() { + const fs = this.getContentFS(); + return fs.getRoot(); + } + + /** + * Check if a file is ignore (should not being parsed, etc) + * + * @param {String} ref + * @return {Page|undefined} + */ + isFileIgnored(filename) { + const ignore = this.getIgnore(); + const language = this.getLanguage(); + + // Ignore is always relative to the root of the main book + if (language) { + filename = path.join(language, filename); + } + + return ignore.isFileIgnored(filename); + } + + /** + * Check if a content file is ignore (should not being parsed, etc) + * + * @param {String} ref + * @return {Page|undefined} + */ + isContentFileIgnored(filename) { + const config = this.getConfig(); + const rootFolder = config.getValue('root'); + + if (rootFolder) { + filename = path.join(rootFolder, filename); + } + + return this.isFileIgnored(filename); + } + + /** + * Return a page from a book by its path + * + * @param {String} ref + * @return {Page|undefined} + */ + getPage(ref) { + return this.getPages().get(ref); + } + + /** + * Is this book the parent of language's books + * @return {Boolean} + */ + isMultilingual() { + return (this.getLanguages().getCount() > 0); + } + + /** + * Return true if book is associated to a language + * @return {Boolean} + */ + isLanguageBook() { + return Boolean(this.getLanguage()); + } + + /** + * Return a languages book + * @param {String} language + * @return {Book} + */ + getLanguageBook(language) { + const books = this.getBooks(); + return books.get(language); + } + + /** + * Add a new language book + * + * @param {String} language + * @param {Book} book + * @return {Book} + */ + addLanguageBook(language, book) { + let books = this.getBooks(); + books = books.set(language, book); + + return this.set('books', books); + } + + /** + * Set the summary for this book + * + * @param {Summary} + * @return {Book} + */ + setSummary(summary) { + return this.set('summary', summary); + } + + /** + * Set the readme for this book + * + * @param {Readme} + * @return {Book} + */ + setReadme(readme) { + return this.set('readme', readme); + } + + /** + * Set the configuration for this book + * + * @param {Config} + * @return {Book} + */ + setConfig(config) { + return this.set('config', config); + } + + /** + * Set the ignore instance for this book + * + @param {Ignore} + * @return {Book} + */ + setIgnore(ignore) { + return this.set('ignore', ignore); + } + + /** + * Change log level + * + * @param {String} level + * @return {Book} + */ + setLogLevel(level) { + this.getLogger().setLevel(level); + return this; + } + + /** + * Infers the default extension for files + * @return {String} + */ + getDefaultExt() { + // Inferring sources + const clues = [ + this.getReadme(), + this.getSummary(), + this.getGlossary() + ]; + + // List their extensions + const exts = clues.map(function(clue) { + const file = clue.getFile(); + if (file.exists()) { + return file.getParser().getExtensions().first(); + } else { + return null; + } + }); + // Adds the general default extension + exts.push('.md'); + + // Choose the first non null + return exts.find(function(e) { return e !== null; }); + } + + /** + * Infer the default path for a Readme. + * @param {Boolean} [absolute=false] False for a path relative to + * this book's content root + * @return {String} + */ + getDefaultReadmePath(absolute) { + const defaultPath = 'README' + this.getDefaultExt(); + if (absolute) { + return path.join(this.getContentRoot(), defaultPath); + } else { + return defaultPath; + } + } + + /** + * Infer the default path for a Summary. + * @param {Boolean} [absolute=false] False for a path relative to + * this book's content root + * @return {String} + */ + getDefaultSummaryPath(absolute) { + const defaultPath = 'SUMMARY' + this.getDefaultExt(); + if (absolute) { + return path.join(this.getContentRoot(), defaultPath); + } else { + return defaultPath; + } + } + + /** + * Infer the default path for a Glossary. + * @param {Boolean} [absolute=false] False for a path relative to + * this book's content root + * @return {String} + */ + getDefaultGlossaryPath(absolute) { + const defaultPath = 'GLOSSARY' + this.getDefaultExt(); + if (absolute) { + return path.join(this.getContentRoot(), defaultPath); + } else { + return defaultPath; + } + } + + /** + * Create a language book from a parent + * + * @param {Book} parent + * @param {String} language + * @return {Book} + */ + static createFromParent(parent, language) { + const ignore = parent.getIgnore(); + let config = parent.getConfig(); + + // Set language in configuration + config = config.setValue('language', language); + + return new Book({ + // Inherits config. logegr and list of ignored files + logger: parent.getLogger(), + config, + ignore, + + language, + fs: FS.reduceScope(parent.getContentFS(), language) + }); + } + + /** + * Create a book using a filesystem + * + * @param {FS} fs + * @return {Book} + */ + static createForFS(fs) { + return new Book({ + fs + }); + } +} + +module.exports = Book; diff --git a/packages/gitbook/src/models/config.js b/packages/gitbook/src/models/config.js new file mode 100644 index 0000000..6a0be5e --- /dev/null +++ b/packages/gitbook/src/models/config.js @@ -0,0 +1,181 @@ +const is = require('is'); +const Immutable = require('immutable'); + +const File = require('./file'); +const PluginDependency = require('./pluginDependency'); +const configDefault = require('../constants/configDefault'); +const reducedObject = require('../utils/reducedObject'); + +const Config = Immutable.Record({ + file: File(), + values: configDefault +}, 'Config'); + +Config.prototype.getFile = function() { + return this.get('file'); +}; + +Config.prototype.getValues = function() { + return this.get('values'); +}; + +/** + * Return minimum version of configuration, + * Basically it returns the current config minus the default one + * @return {Map} + */ +Config.prototype.toReducedVersion = function() { + return reducedObject(configDefault, this.getValues()); +}; + +/** + * Render config as text + * @return {Promise<String>} + */ +Config.prototype.toText = function() { + return JSON.stringify(this.toReducedVersion().toJS(), null, 4); +}; + +/** + * Change the file for the configuration + * @param {File} file + * @return {Config} + */ +Config.prototype.setFile = function(file) { + return this.set('file', file); +}; + +/** + * Return a configuration value by its key path + * @param {String} key + * @return {Mixed} + */ +Config.prototype.getValue = function(keyPath, def) { + const values = this.getValues(); + keyPath = Config.keyToKeyPath(keyPath); + + if (!values.hasIn(keyPath)) { + return Immutable.fromJS(def); + } + + return values.getIn(keyPath); +}; + +/** + * Update a configuration value + * @param {String} key + * @param {Mixed} value + * @return {Config} + */ +Config.prototype.setValue = function(keyPath, value) { + keyPath = Config.keyToKeyPath(keyPath); + + value = Immutable.fromJS(value); + + let values = this.getValues(); + values = values.setIn(keyPath, value); + + return this.set('values', values); +}; + +/** + * Return a list of plugin dependencies + * @return {List<PluginDependency>} + */ +Config.prototype.getPluginDependencies = function() { + const plugins = this.getValue('plugins'); + + if (is.string(plugins)) { + return PluginDependency.listFromString(plugins); + } else { + return PluginDependency.listFromArray(plugins); + } +}; + +/** + * Return a plugin dependency by its name + * @param {String} name + * @return {PluginDependency} + */ +Config.prototype.getPluginDependency = function(name) { + const plugins = this.getPluginDependencies(); + + return plugins.find(function(dep) { + return dep.getName() === name; + }); +}; + +/** + * Update the list of plugins dependencies + * @param {List<PluginDependency>} + * @return {Config} + */ +Config.prototype.setPluginDependencies = function(deps) { + const plugins = PluginDependency.listToArray(deps); + + return this.setValue('plugins', plugins); +}; + + +/** + * Update values for an existing configuration + * @param {Object} values + * @returns {Config} + */ +Config.prototype.updateValues = function(values) { + values = Immutable.fromJS(values); + + return this.set('values', values); +}; + +/** + * Update values for an existing configuration + * @param {Config} config + * @param {Object} values + * @returns {Config} + */ +Config.prototype.mergeValues = function(values) { + let currentValues = this.getValues(); + values = Immutable.fromJS(values); + + currentValues = currentValues.mergeDeep(values); + + return this.set('values', currentValues); +}; + +/** + * Create a new config for a file + * @param {File} file + * @param {Object} values + * @returns {Config} + */ +Config.create = function(file, values) { + return new Config({ + file, + values: Immutable.fromJS(values) + }); +}; + +/** + * Create a new config + * @param {Object} values + * @returns {Config} + */ +Config.createWithValues = function(values) { + return new Config({ + values: Immutable.fromJS(values) + }); +}; + + +/** + * Convert a keyPath to an array of keys + * @param {String|Array} + * @return {Array} + */ +Config.keyToKeyPath = function(keyPath) { + if (is.string(keyPath)) keyPath = keyPath.split('.'); + return keyPath; +}; + +module.exports = Config; diff --git a/packages/gitbook/src/models/file.js b/packages/gitbook/src/models/file.js new file mode 100644 index 0000000..84828ce --- /dev/null +++ b/packages/gitbook/src/models/file.js @@ -0,0 +1,89 @@ +const path = require('path'); +const Immutable = require('immutable'); + +const parsers = require('../parsers'); + +const File = Immutable.Record({ + // Path of the file, relative to the FS + path: String(), + + // Time when file data last modified + mtime: Date() +}); + +File.prototype.getPath = function() { + return this.get('path'); +}; + +File.prototype.getMTime = function() { + return this.get('mtime'); +}; + +/** + Does the file exists / is set + + @return {Boolean} +*/ +File.prototype.exists = function() { + return Boolean(this.getPath()); +}; + +/** + Return type of file ('markdown' or 'asciidoc') + + @return {String} +*/ +File.prototype.getType = function() { + const parser = this.getParser(); + if (parser) { + return parser.getName(); + } else { + return undefined; + } +}; + +/** + Return extension of this file (lowercased) + + @return {String} +*/ +File.prototype.getExtension = function() { + return path.extname(this.getPath()).toLowerCase(); +}; + +/** + Return parser for this file + + @return {Parser} +*/ +File.prototype.getParser = function() { + return parsers.getByExt(this.getExtension()); +}; + +/** + Create a file from stats informations + + @param {String} filepath + @param {Object|fs.Stats} stat + @return {File} +*/ +File.createFromStat = function createFromStat(filepath, stat) { + return new File({ + path: filepath, + mtime: stat.mtime + }); +}; + +/** + Create a file with only a path + + @param {String} filepath + @return {File} +*/ +File.createWithFilepath = function createWithFilepath(filepath) { + return new File({ + path: filepath + }); +}; + +module.exports = File; diff --git a/packages/gitbook/src/models/fs.js b/packages/gitbook/src/models/fs.js new file mode 100644 index 0000000..7afbfbd --- /dev/null +++ b/packages/gitbook/src/models/fs.js @@ -0,0 +1,300 @@ +const path = require('path'); +const Immutable = require('immutable'); +const stream = require('stream'); + +const File = require('./file'); +const Promise = require('../utils/promise'); +const error = require('../utils/error'); +const PathUtil = require('../utils/path'); + +const FS = Immutable.Record({ + root: String(), + + fsExists: Function(), + fsReadFile: Function(), + fsStatFile: Function(), + fsReadDir: Function(), + + fsLoadObject: null, + fsReadAsStream: null +}); + +/** + Return path to the root + + @return {String} +*/ +FS.prototype.getRoot = function() { + return this.get('root'); +}; + +/** + Verify that a file is in the fs scope + + @param {String} filename + @return {Boolean} +*/ +FS.prototype.isInScope = function(filename) { + const rootPath = this.getRoot(); + filename = path.join(rootPath, filename); + + return PathUtil.isInRoot(rootPath, filename); +}; + +/** + * Resolve a file in this FS + * @param {String} + * @return {String} + */ +FS.prototype.resolve = function(...args) { + const rootPath = this.getRoot(); + let filename = path.join(rootPath, ...args); + filename = path.normalize(filename); + + if (!this.isInScope(filename)) { + throw error.FileOutOfScopeError({ + filename, + root: this.root + }); + } + + return filename; +}; + +/** + * Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise + * @param {String} filename + * @return {Promise<Boolean>} + */ +FS.prototype.exists = function(filename) { + const that = this; + + return Promise() + .then(function() { + filename = that.resolve(filename); + const exists = that.get('fsExists'); + + return exists(filename); + }); +}; + +/** + * Read a file and returns a promise with the content as a buffer + * @param {String} filename + * @return {Promise<Buffer>} + */ +FS.prototype.read = function(filename) { + const that = this; + + return Promise() + .then(function() { + filename = that.resolve(filename); + const read = that.get('fsReadFile'); + + return read(filename); + }); +}; + +/** + * Read a file as a string (utf-8) + * @param {String} filename + * @return {Promise<String>} + */ +FS.prototype.readAsString = function(filename, encoding) { + encoding = encoding || 'utf8'; + + return this.read(filename) + .then(function(buf) { + return buf.toString(encoding); + }); +}; + +/** + * Read file as a stream + * @param {String} filename + * @return {Promise<Stream>} + */ +FS.prototype.readAsStream = function(filename) { + const that = this; + const filepath = that.resolve(filename); + const fsReadAsStream = this.get('fsReadAsStream'); + + if (fsReadAsStream) { + return Promise(fsReadAsStream(filepath)); + } + + return this.read(filename) + .then(function(buf) { + const bufferStream = new stream.PassThrough(); + bufferStream.end(buf); + + return bufferStream; + }); +}; + +/** + * Read stat infos about a file + * @param {String} filename + * @return {Promise<File>} + */ +FS.prototype.statFile = function(filename) { + const that = this; + + return Promise() + .then(function() { + const filepath = that.resolve(filename); + const stat = that.get('fsStatFile'); + + return stat(filepath); + }) + .then(function(stat) { + return File.createFromStat(filename, stat); + }); +}; + +/** + * List files/directories in a directory. + * Directories ends with '/' + + * @param {String} dirname + * @return {Promise<List<String>>} + */ +FS.prototype.readDir = function(dirname) { + const that = this; + + return Promise() + .then(function() { + const dirpath = that.resolve(dirname); + const readDir = that.get('fsReadDir'); + + return readDir(dirpath); + }) + .then(function(files) { + return Immutable.List(files); + }); +}; + +/** + * List only files in a diretcory + * Directories ends with '/' + * + * @param {String} dirname + * @return {Promise<List<String>>} + */ +FS.prototype.listFiles = function(dirname) { + return this.readDir(dirname) + .then(function(files) { + return files.filterNot(pathIsFolder); + }); +}; + +/** + * List all files in a directory + * + * @param {String} dirName + * @param {Function(dirName)} filterFn: call it for each file/directory to test if it should stop iterating + * @return {Promise<List<String>>} + */ +FS.prototype.listAllFiles = function(dirName, filterFn) { + const that = this; + dirName = dirName || '.'; + + return this.readDir(dirName) + .then(function(files) { + return Promise.reduce(files, function(out, file) { + const isDirectory = pathIsFolder(file); + const newDirName = path.join(dirName, file); + + if (filterFn && filterFn(newDirName) === false) { + return out; + } + + if (!isDirectory) { + return out.push(newDirName); + } + + return that.listAllFiles(newDirName, filterFn) + .then(function(inner) { + return out.concat(inner); + }); + }, Immutable.List()); + }); +}; + +/** + * Find a file in a folder (case insensitive) + * Return the found filename + * + * @param {String} dirname + * @param {String} filename + * @return {Promise<String>} + */ +FS.prototype.findFile = function(dirname, filename) { + return this.listFiles(dirname) + .then(function(files) { + return files.find(function(file) { + return (file.toLowerCase() == filename.toLowerCase()); + }); + }); +}; + +/** + * Load a JSON file + * By default, fs only supports JSON + * + * @param {String} filename + * @return {Promise<Object>} + */ +FS.prototype.loadAsObject = function(filename) { + const that = this; + const fsLoadObject = this.get('fsLoadObject'); + + return this.exists(filename) + .then(function(exists) { + if (!exists) { + const err = new Error('Module doesn\'t exist'); + err.code = 'MODULE_NOT_FOUND'; + + throw err; + } + + if (fsLoadObject) { + return fsLoadObject(that.resolve(filename)); + } else { + return that.readAsString(filename) + .then(function(str) { + return JSON.parse(str); + }); + } + }); +}; + +/** + * Create a FS instance + * + * @param {Object} def + * @return {FS} + */ +FS.create = function create(def) { + return new FS(def); +}; + +/** + * Create a new FS instance with a reduced scope + * + * @param {FS} fs + * @param {String} scope + * @return {FS} + */ +FS.reduceScope = function reduceScope(fs, scope) { + return fs.set('root', path.join(fs.getRoot(), scope)); +}; + + +// .readdir return files/folder as a list of string, folder ending with '/' +function pathIsFolder(filename) { + const lastChar = filename[filename.length - 1]; + return lastChar == '/' || lastChar == '\\'; +} + +module.exports = FS; diff --git a/packages/gitbook/src/models/glossary.js b/packages/gitbook/src/models/glossary.js new file mode 100644 index 0000000..e269b14 --- /dev/null +++ b/packages/gitbook/src/models/glossary.js @@ -0,0 +1,109 @@ +const Immutable = require('immutable'); + +const error = require('../utils/error'); +const File = require('./file'); +const GlossaryEntry = require('./glossaryEntry'); +const parsers = require('../parsers'); + +const Glossary = Immutable.Record({ + file: File(), + entries: Immutable.OrderedMap() +}); + +Glossary.prototype.getFile = function() { + return this.get('file'); +}; + +Glossary.prototype.getEntries = function() { + return this.get('entries'); +}; + +/** + Return an entry by its name + + @param {String} name + @return {GlossaryEntry} +*/ +Glossary.prototype.getEntry = function(name) { + const entries = this.getEntries(); + const id = GlossaryEntry.nameToID(name); + + return entries.get(id); +}; + +/** + Render glossary as text + + @return {Promise<String>} +*/ +Glossary.prototype.toText = function(parser) { + const file = this.getFile(); + const entries = this.getEntries(); + + parser = parser ? parsers.getByExt(parser) : file.getParser(); + + if (!parser) { + throw error.FileNotParsableError({ + filename: file.getPath() + }); + } + + return parser.renderGlossary(entries.toJS()); +}; + + +/** + Add/Replace an entry to a glossary + + @param {Glossary} glossary + @param {GlossaryEntry} entry + @return {Glossary} +*/ +Glossary.addEntry = function addEntry(glossary, entry) { + const id = entry.getID(); + let entries = glossary.getEntries(); + + entries = entries.set(id, entry); + return glossary.set('entries', entries); +}; + +/** + Add/Replace an entry to a glossary by name/description + + @param {Glossary} glossary + @param {GlossaryEntry} entry + @return {Glossary} +*/ +Glossary.addEntryByName = function addEntryByName(glossary, name, description) { + const entry = new GlossaryEntry({ + name, + description + }); + + return Glossary.addEntry(glossary, entry); +}; + +/** + Create a glossary from a list of entries + + @param {String} filename + @param {Array|List} entries + @return {Glossary} +*/ +Glossary.createFromEntries = function createFromEntries(file, entries) { + entries = entries.map(function(entry) { + if (!(entry instanceof GlossaryEntry)) { + entry = new GlossaryEntry(entry); + } + + return [entry.getID(), entry]; + }); + + return new Glossary({ + file, + entries: Immutable.OrderedMap(entries) + }); +}; + + +module.exports = Glossary; diff --git a/packages/gitbook/src/models/glossaryEntry.js b/packages/gitbook/src/models/glossaryEntry.js new file mode 100644 index 0000000..b36b276 --- /dev/null +++ b/packages/gitbook/src/models/glossaryEntry.js @@ -0,0 +1,43 @@ +const Immutable = require('immutable'); +const slug = require('github-slugid'); + +/* + A definition represents an entry in the glossary +*/ + +const GlossaryEntry = Immutable.Record({ + name: String(), + description: String() +}); + +GlossaryEntry.prototype.getName = function() { + return this.get('name'); +}; + +GlossaryEntry.prototype.getDescription = function() { + return this.get('description'); +}; + + +/** + Get identifier for this entry + + @retrun {Boolean} +*/ +GlossaryEntry.prototype.getID = function() { + return GlossaryEntry.nameToID(this.getName()); +}; + + +/** + Normalize a glossary entry name into a unique id + + @param {String} + @return {String} +*/ +GlossaryEntry.nameToID = function nameToID(name) { + return slug(name); +}; + + +module.exports = GlossaryEntry; diff --git a/packages/gitbook/src/models/ignore.js b/packages/gitbook/src/models/ignore.js new file mode 100644 index 0000000..547f6b4 --- /dev/null +++ b/packages/gitbook/src/models/ignore.js @@ -0,0 +1,43 @@ +const { Record } = require('immutable'); +const IgnoreMutable = require('ignore'); + +/* + Immutable version of node-ignore +*/ + +const DEFAULTS = { + ignore: new IgnoreMutable() +}; + +class Ignore extends Record(DEFAULTS) { + getIgnore() { + return this.get('ignore'); + } + + /** + * Test if a file is ignored by these rules. + * @param {String} filePath + * @return {Boolean} isIgnored + */ + isFileIgnored(filename) { + const ignore = this.getIgnore(); + return ignore.filter([filename]).length == 0; + } + + /** + * Add rules. + * @param {String} + * @return {Ignore} + */ + add(rule) { + const ignore = this.getIgnore(); + const newIgnore = new IgnoreMutable(); + + newIgnore.add(ignore); + newIgnore.add(rule); + + return this.set('ignore', newIgnore); + } +} + +module.exports = Ignore; diff --git a/packages/gitbook/src/models/language.js b/packages/gitbook/src/models/language.js new file mode 100644 index 0000000..1413091 --- /dev/null +++ b/packages/gitbook/src/models/language.js @@ -0,0 +1,21 @@ +const path = require('path'); +const Immutable = require('immutable'); + +const Language = Immutable.Record({ + title: String(), + path: String() +}); + +Language.prototype.getTitle = function() { + return this.get('title'); +}; + +Language.prototype.getPath = function() { + return this.get('path'); +}; + +Language.prototype.getID = function() { + return path.basename(this.getPath()); +}; + +module.exports = Language; diff --git a/packages/gitbook/src/models/languages.js b/packages/gitbook/src/models/languages.js new file mode 100644 index 0000000..9540546 --- /dev/null +++ b/packages/gitbook/src/models/languages.js @@ -0,0 +1,71 @@ +const Immutable = require('immutable'); + +const File = require('./file'); +const Language = require('./language'); + +const Languages = Immutable.Record({ + file: File(), + list: Immutable.OrderedMap() +}); + +Languages.prototype.getFile = function() { + return this.get('file'); +}; + +Languages.prototype.getList = function() { + return this.get('list'); +}; + +/** + Get default languages + + @return {Language} +*/ +Languages.prototype.getDefaultLanguage = function() { + return this.getList().first(); +}; + +/** + Get a language by its ID + + @param {String} lang + @return {Language} +*/ +Languages.prototype.getLanguage = function(lang) { + return this.getList().get(lang); +}; + +/** + Return count of langs + + @return {Number} +*/ +Languages.prototype.getCount = function() { + return this.getList().size; +}; + +/** + Create a languages list from a JS object + + @param {File} + @param {Array} + @return {Language} +*/ +Languages.createFromList = function(file, langs) { + let list = Immutable.OrderedMap(); + + langs.forEach(function(lang) { + lang = Language({ + title: lang.title, + path: lang.ref + }); + list = list.set(lang.getID(), lang); + }); + + return Languages({ + file, + list + }); +}; + +module.exports = Languages; diff --git a/packages/gitbook/src/models/output.js b/packages/gitbook/src/models/output.js new file mode 100644 index 0000000..a63be17 --- /dev/null +++ b/packages/gitbook/src/models/output.js @@ -0,0 +1,112 @@ +const { Record, OrderedMap, Map, List } = require('immutable'); + +const Git = require('../utils/git'); +const LocationUtils = require('../utils/location'); +const Book = require('./book'); +const URIIndex = require('./uriIndex'); + +const DEFAULTS = { + book: new Book(), + // Name of the generator being used + generator: String(), + // Map of plugins to use (String -> Plugin) + plugins: OrderedMap(), + // Map pages to generation (String -> Page) + pages: OrderedMap(), + // List of file that are not pages in the book (String) + assets: List(), + // Option for the generation + options: Map(), + // Internal state for the generation + state: Map(), + // Index of urls + urls: new URIIndex(), + // Git repositories manager + git: new Git() +}; + +class Output extends Record(DEFAULTS) { + getBook() { + return this.get('book'); + } + + getGenerator() { + return this.get('generator'); + } + + getPlugins() { + return this.get('plugins'); + } + + getPages() { + return this.get('pages'); + } + + getOptions() { + return this.get('options'); + } + + getAssets() { + return this.get('assets'); + } + + getState() { + return this.get('state'); + } + + getURLIndex() { + return this.get('urls'); + } + + /** + * Return a page byt its file path + * + * @param {String} filePath + * @return {Page|undefined} + */ + getPage(filePath) { + filePath = LocationUtils.normalize(filePath); + + const pages = this.getPages(); + return pages.get(filePath); + } + + /** + * Get root folder for output. + * @return {String} + */ + getRoot() { + return this.getOptions().get('root'); + } + + /** + * Update state of output + * + * @param {Map} newState + * @return {Output} + */ + setState(newState) { + return this.set('state', newState); + } + + /** + * Update options + * + * @param {Map} newOptions + * @return {Output} + */ + setOptions(newOptions) { + return this.set('options', newOptions); + } + + /** + * Return logegr for this output (same as book) + * + * @return {Logger} + */ + getLogger() { + return this.getBook().getLogger(); + } +} + +module.exports = Output; diff --git a/packages/gitbook/src/models/page.js b/packages/gitbook/src/models/page.js new file mode 100644 index 0000000..e2ab977 --- /dev/null +++ b/packages/gitbook/src/models/page.js @@ -0,0 +1,69 @@ +const { Record, Map } = require('immutable'); +const yaml = require('js-yaml'); + +const File = require('./file'); + +const DEFAULTS = { + file: File(), + // Attributes extracted from the YAML header + attributes: Map(), + // Content of the page + content: String(), + // Direction of the text + dir: String('ltr') +}; + +class Page extends Record(DEFAULTS) { + getFile() { + return this.get('file'); + } + + getAttributes() { + return this.get('attributes'); + } + + getContent() { + return this.get('content'); + } + + getDir() { + return this.get('dir'); + } + + /** + * Return page as text + * @return {String} + */ + toText() { + const attrs = this.getAttributes(); + const content = this.getContent(); + + if (attrs.size === 0) { + return content; + } + + const frontMatter = '---\n' + yaml.safeDump(attrs.toJS(), { skipInvalid: true }) + '---\n\n'; + return (frontMatter + content); + } + + /** + * Return path of the page + * @return {String} + */ + getPath() { + return this.getFile().getPath(); + } + + /** + * Create a page for a file + * @param {File} file + * @return {Page} + */ + static createForFile(file) { + return new Page({ + file + }); + } +} + +module.exports = Page; diff --git a/packages/gitbook/src/models/parser.js b/packages/gitbook/src/models/parser.js new file mode 100644 index 0000000..3769dd3 --- /dev/null +++ b/packages/gitbook/src/models/parser.js @@ -0,0 +1,122 @@ +const Immutable = require('immutable'); +const Promise = require('../utils/promise'); + +const Parser = Immutable.Record({ + name: String(), + + // List of extensions that can be processed using this parser + extensions: Immutable.List(), + + // Parsing functions + readme: Function(), + langs: Function(), + summary: Function(), + glossary: Function(), + page: Function(), + inline: Function() +}); + +Parser.prototype.getName = function() { + return this.get('name'); +}; + +Parser.prototype.getExtensions = function() { + return this.get('extensions'); +}; + +// PARSE + +Parser.prototype.parseReadme = function(content) { + const readme = this.get('readme'); + return Promise(readme(content)); +}; + +Parser.prototype.parseSummary = function(content) { + const summary = this.get('summary'); + return Promise(summary(content)); +}; + +Parser.prototype.parseGlossary = function(content) { + const glossary = this.get('glossary'); + return Promise(glossary(content)); +}; + +Parser.prototype.preparePage = function(content) { + const page = this.get('page'); + if (!page.prepare) { + return Promise(content); + } + + return Promise(page.prepare(content)); +}; + +Parser.prototype.parsePage = function(content) { + const page = this.get('page'); + return Promise(page(content)); +}; + +Parser.prototype.parseInline = function(content) { + const inline = this.get('inline'); + return Promise(inline(content)); +}; + +Parser.prototype.parseLanguages = function(content) { + const langs = this.get('langs'); + return Promise(langs(content)); +}; + +Parser.prototype.parseInline = function(content) { + const inline = this.get('inline'); + return Promise(inline(content)); +}; + +// TO TEXT + +Parser.prototype.renderLanguages = function(content) { + const langs = this.get('langs'); + return Promise(langs.toText(content)); +}; + +Parser.prototype.renderSummary = function(content) { + const summary = this.get('summary'); + return Promise(summary.toText(content)); +}; + +Parser.prototype.renderGlossary = function(content) { + const glossary = this.get('glossary'); + return Promise(glossary.toText(content)); +}; + +/** + Test if this parser matches an extension + + @param {String} ext + @return {Boolean} +*/ +Parser.prototype.matchExtension = function(ext) { + const exts = this.getExtensions(); + return exts.includes(ext.toLowerCase()); +}; + +/** + Create a new parser using a module (gitbook-markdown, etc) + + @param {String} name + @param {Array<String>} extensions + @param {Object} module + @return {Parser} +*/ +Parser.create = function(name, extensions, module) { + return new Parser({ + name, + extensions: Immutable.List(extensions), + readme: module.readme, + langs: module.langs, + summary: module.summary, + glossary: module.glossary, + page: module.page, + inline: module.inline + }); +}; + +module.exports = Parser; diff --git a/packages/gitbook/src/models/plugin.js b/packages/gitbook/src/models/plugin.js new file mode 100644 index 0000000..f2491f2 --- /dev/null +++ b/packages/gitbook/src/models/plugin.js @@ -0,0 +1,149 @@ +const { Record, Map } = require('immutable'); + +const TemplateBlock = require('./templateBlock'); +const PluginDependency = require('./pluginDependency'); +const THEME_PREFIX = require('../constants/themePrefix'); + +const DEFAULT_VERSION = '*'; + +const DEFAULTS = { + name: String(), + // Requirement version (ex: ">1.0.0") + version: String(DEFAULT_VERSION), + // Path to load this plugin + path: String(), + // Depth of this plugin in the dependency tree + depth: Number(0), + // Parent depending on this plugin + parent: String(), + // Content of the "package.json" + package: Map(), + // Content of the package itself + content: Map() +}; + +class Plugin extends Record(DEFAULTS) { + getName() { + return this.get('name'); + } + + getPath() { + return this.get('path'); + } + + getVersion() { + return this.get('version'); + } + + getPackage() { + return this.get('package'); + } + + getContent() { + return this.get('content'); + } + + getDepth() { + return this.get('depth'); + } + + getParent() { + return this.get('parent'); + } + + /** + * Return the ID on NPM for this plugin + * @return {String} + */ + getNpmID() { + return PluginDependency.nameToNpmID(this.getName()); + } + + /** + * Check if a plugin is loaded + * @return {Boolean} + */ + isLoaded() { + return Boolean(this.getPackage().size > 0); + } + + /** + * Check if a plugin is a theme given its name + * @return {Boolean} + */ + isTheme() { + const name = this.getName(); + return (name && name.indexOf(THEME_PREFIX) === 0); + } + + /** + * Return map of hooks + * @return {Map<String:Function>} + */ + getHooks() { + return this.getContent().get('hooks') || Map(); + } + + /** + * Return map of filters + * @return {Map<String:Function>} + */ + getFilters() { + return this.getContent().get('filters'); + } + + /** + * Return map of blocks + * @return {Map<String:TemplateBlock>} + */ + getBlocks() { + let blocks = this.getContent().get('blocks'); + blocks = blocks || Map(); + + return blocks + .map(function(block, blockName) { + return TemplateBlock.create(blockName, block); + }); + } + + /** + * Return a specific hook + * @param {String} name + * @return {Function|undefined} + */ + getHook(name) { + return this.getHooks().get(name); + } + + /** + * Create a plugin from a string + * @param {String} + * @return {Plugin} + */ + static createFromString(s) { + const parts = s.split('@'); + const name = parts[0]; + const version = parts.slice(1).join('@'); + + return new Plugin({ + name, + version: version || DEFAULT_VERSION + }); + } + + /** + * Create a plugin from a dependency + * @param {PluginDependency} + * @return {Plugin} + */ + static createFromDep(dep) { + return new Plugin({ + name: dep.getName(), + version: dep.getVersion() + }); + } +} + +Plugin.nameToNpmID = PluginDependency.nameToNpmID; + +module.exports = Plugin; diff --git a/packages/gitbook/src/models/pluginDependency.js b/packages/gitbook/src/models/pluginDependency.js new file mode 100644 index 0000000..4e5d464 --- /dev/null +++ b/packages/gitbook/src/models/pluginDependency.js @@ -0,0 +1,168 @@ +const is = require('is'); +const semver = require('semver'); +const Immutable = require('immutable'); + +const PREFIX = require('../constants/pluginPrefix'); +const DEFAULT_VERSION = '*'; + +/* + * PluginDependency represents the informations about a plugin + * stored in config.plugins + */ +const PluginDependency = Immutable.Record({ + name: String(), + + // Requirement version (ex: ">1.0.0") + version: String(DEFAULT_VERSION), + + // Is this plugin enabled or disabled? + enabled: Boolean(true) +}, 'PluginDependency'); + +PluginDependency.prototype.getName = function() { + return this.get('name'); +}; + +PluginDependency.prototype.getVersion = function() { + return this.get('version'); +}; + +PluginDependency.prototype.isEnabled = function() { + return this.get('enabled'); +}; + +/** + * Toggle this plugin state + * @param {Boolean} + * @return {PluginDependency} + */ +PluginDependency.prototype.toggle = function(state) { + if (is.undef(state)) { + state = !this.isEnabled(); + } + + return this.set('enabled', state); +}; + +/** + * Return NPM ID for the dependency + * @return {String} + */ +PluginDependency.prototype.getNpmID = function() { + return PluginDependency.nameToNpmID(this.getName()); +}; + +/** + * Is the plugin using a git dependency + * @return {Boolean} + */ +PluginDependency.prototype.isGitDependency = function() { + return !semver.validRange(this.getVersion()); +}; + +/** + * Create a plugin with a name and a plugin + * @param {String} + * @return {Plugin|undefined} + */ +PluginDependency.create = function(name, version, enabled) { + if (is.undefined(enabled)) { + enabled = true; + } + + return new PluginDependency({ + name, + version: version || DEFAULT_VERSION, + enabled: Boolean(enabled) + }); +}; + +/** + * Create a plugin from a string + * @param {String} + * @return {Plugin|undefined} + */ +PluginDependency.createFromString = function(s) { + const parts = s.split('@'); + let name = parts[0]; + const version = parts.slice(1).join('@'); + let enabled = true; + + if (name[0] === '-') { + enabled = false; + name = name.slice(1); + } + + return new PluginDependency({ + name, + version: version || DEFAULT_VERSION, + enabled + }); +}; + +/** + * Create a PluginDependency from a string + * @param {String} + * @return {List<PluginDependency>} + */ +PluginDependency.listFromString = function(s) { + const parts = s.split(','); + return PluginDependency.listFromArray(parts); +}; + +/** + * Create a PluginDependency from an array + * @param {Array} + * @return {List<PluginDependency>} + */ +PluginDependency.listFromArray = function(arr) { + return Immutable.List(arr) + .map(function(entry) { + if (is.string(entry)) { + return PluginDependency.createFromString(entry); + } else { + return PluginDependency({ + name: entry.get('name'), + version: entry.get('version') + }); + } + }) + .filter(function(dep) { + return Boolean(dep.getName()); + }); +}; + +/** + * Export plugin dependencies as an array + * @param {List<PluginDependency>} list + * @return {Array<String>} + */ +PluginDependency.listToArray = function(list) { + return list + .map(function(dep) { + let result = ''; + + if (!dep.isEnabled()) { + result += '-'; + } + + result += dep.getName(); + if (dep.getVersion() !== DEFAULT_VERSION) { + result += '@' + dep.getVersion(); + } + + return result; + }) + .toJS(); +}; + +/** + * Return NPM id for a plugin name + * @param {String} + * @return {String} + */ +PluginDependency.nameToNpmID = function(s) { + return PREFIX + s; +}; + +module.exports = PluginDependency; diff --git a/packages/gitbook/src/models/readme.js b/packages/gitbook/src/models/readme.js new file mode 100644 index 0000000..0fb52b4 --- /dev/null +++ b/packages/gitbook/src/models/readme.js @@ -0,0 +1,40 @@ +const Immutable = require('immutable'); + +const File = require('./file'); + +const Readme = Immutable.Record({ + file: File(), + title: String(), + description: String() +}); + +Readme.prototype.getFile = function() { + return this.get('file'); +}; + +Readme.prototype.getTitle = function() { + return this.get('title'); +}; + +Readme.prototype.getDescription = function() { + return this.get('description'); +}; + +/** + Create a new readme + + @param {File} file + @param {Object} def + @return {Readme} +*/ +Readme.create = function(file, def) { + def = def || {}; + + return new Readme({ + file, + title: def.title || '', + description: def.description || '' + }); +}; + +module.exports = Readme; diff --git a/packages/gitbook/src/models/summary.js b/packages/gitbook/src/models/summary.js new file mode 100644 index 0000000..edc202e --- /dev/null +++ b/packages/gitbook/src/models/summary.js @@ -0,0 +1,228 @@ +const is = require('is'); +const Immutable = require('immutable'); + +const error = require('../utils/error'); +const LocationUtils = require('../utils/location'); +const File = require('./file'); +const SummaryPart = require('./summaryPart'); +const SummaryArticle = require('./summaryArticle'); +const parsers = require('../parsers'); + +const Summary = Immutable.Record({ + file: File(), + parts: Immutable.List() +}, 'Summary'); + +Summary.prototype.getFile = function() { + return this.get('file'); +}; + +Summary.prototype.getParts = function() { + return this.get('parts'); +}; + +/** + Return a part by its index + + @param {Number} + @return {Part} +*/ +Summary.prototype.getPart = function(i) { + const parts = this.getParts(); + return parts.get(i); +}; + +/** + Return an article using an iterator to find it. + if "partIter" is set, it can also return a Part. + + @param {Function} iter + @param {Function} partIter + @return {Article|Part} +*/ +Summary.prototype.getArticle = function(iter, partIter) { + const parts = this.getParts(); + + return parts.reduce(function(result, part) { + if (result) return result; + + if (partIter && partIter(part)) return part; + return SummaryArticle.findArticle(part, iter); + }, null); +}; + + +/** + Return a part/article by its level + + @param {String} level + @return {Article|Part} +*/ +Summary.prototype.getByLevel = function(level) { + function iterByLevel(article) { + return (article.getLevel() === level); + } + + return this.getArticle(iterByLevel, iterByLevel); +}; + +/** + Return an article by its path + + @param {String} filePath + @return {Article} +*/ +Summary.prototype.getByPath = function(filePath) { + return this.getArticle(function(article) { + const articlePath = article.getPath(); + + return ( + articlePath && + LocationUtils.areIdenticalPaths(articlePath, filePath) + ); + }); +}; + +/** + Return the first article + + @return {Article} +*/ +Summary.prototype.getFirstArticle = function() { + return this.getArticle(function(article) { + return true; + }); +}; + +/** + Return next article of an article + + @param {Article} current + @return {Article} +*/ +Summary.prototype.getNextArticle = function(current) { + const level = is.string(current) ? current : current.getLevel(); + let wasPrev = false; + + return this.getArticle(function(article) { + if (wasPrev) return true; + + wasPrev = article.getLevel() == level; + return false; + }); +}; + +/** + Return previous article of an article + + @param {Article} current + @return {Article} +*/ +Summary.prototype.getPrevArticle = function(current) { + const level = is.string(current) ? current : current.getLevel(); + let prev = undefined; + + this.getArticle(function(article) { + if (article.getLevel() == level) { + return true; + } + + prev = article; + return false; + }); + + return prev; +}; + +/** + Return the parent article, or parent part of an article + + @param {String|Article} current + @return {Article|Part|Null} +*/ +Summary.prototype.getParent = function(level) { + // Coerce to level + level = is.string(level) ? level : level.getLevel(); + + // Get parent level + const parentLevel = getParentLevel(level); + if (!parentLevel) { + return null; + } + + // Get parent of the position + const parentArticle = this.getByLevel(parentLevel); + return parentArticle || null; +}; + +/** + Render summary as text + + @param {String} parseExt Extension of the parser to use + @return {Promise<String>} +*/ +Summary.prototype.toText = function(parseExt) { + const file = this.getFile(); + const parts = this.getParts(); + + const parser = parseExt ? parsers.getByExt(parseExt) : file.getParser(); + + if (!parser) { + throw error.FileNotParsableError({ + filename: file.getPath() + }); + } + + return parser.renderSummary({ + parts: parts.toJS() + }); +}; + +/** + Return all articles as a list + + @return {List<Article>} +*/ +Summary.prototype.getArticlesAsList = function() { + const accu = []; + + this.getArticle(function(article) { + accu.push(article); + }); + + return Immutable.List(accu); +}; + +/** + Create a new summary for a list of parts + + @param {Lust|Array} parts + @return {Summary} +*/ +Summary.createFromParts = function createFromParts(file, parts) { + parts = parts.map(function(part, i) { + if (part instanceof SummaryPart) { + return part; + } + + return SummaryPart.create(part, i + 1); + }); + + return new Summary({ + file, + parts: new Immutable.List(parts) + }); +}; + +/** + Returns parent level of a level + + @param {String} level + @return {String} +*/ +function getParentLevel(level) { + const parts = level.split('.'); + return parts.slice(0, -1).join('.'); +} + +module.exports = Summary; diff --git a/packages/gitbook/src/models/summaryArticle.js b/packages/gitbook/src/models/summaryArticle.js new file mode 100644 index 0000000..919e6b9 --- /dev/null +++ b/packages/gitbook/src/models/summaryArticle.js @@ -0,0 +1,189 @@ +const Immutable = require('immutable'); + +const location = require('../utils/location'); + +/* + An article represents an entry in the Summary / table of Contents +*/ + +const SummaryArticle = Immutable.Record({ + level: String(), + title: String(), + ref: String(), + articles: Immutable.List() +}, 'SummaryArticle'); + +SummaryArticle.prototype.getLevel = function() { + return this.get('level'); +}; + +SummaryArticle.prototype.getTitle = function() { + return this.get('title'); +}; + +SummaryArticle.prototype.getRef = function() { + return this.get('ref'); +}; + +SummaryArticle.prototype.getArticles = function() { + return this.get('articles'); +}; + +/** + * Return how deep the article is. + * The README has a depth of 1 + * + * @return {Number} + */ +SummaryArticle.prototype.getDepth = function() { + return (this.getLevel().split('.').length - 1); +}; + +/** + * Get path (without anchor) to the pointing file. + * It also normalizes the file path. + * + * @return {String} + */ +SummaryArticle.prototype.getPath = function() { + if (this.isExternal()) { + return undefined; + } + + const ref = this.getRef(); + if (!ref) { + return undefined; + } + + const parts = ref.split('#'); + + const pathname = (parts.length > 1 ? parts.slice(0, -1).join('#') : ref); + + // Normalize path to remove ('./', '/...', etc) + return location.flatten(pathname); +}; + +/** + * Return url if article is external + * + * @return {String} + */ +SummaryArticle.prototype.getUrl = function() { + return this.isExternal() ? this.getRef() : undefined; +}; + +/** + * Get anchor for this article (or undefined) + * + * @return {String} + */ +SummaryArticle.prototype.getAnchor = function() { + const ref = this.getRef(); + const parts = ref.split('#'); + + const anchor = (parts.length > 1 ? '#' + parts[parts.length - 1] : undefined); + return anchor; +}; + +/** + * Create a new level for a new child article + * + * @return {String} + */ +SummaryArticle.prototype.createChildLevel = function() { + const level = this.getLevel(); + const subArticles = this.getArticles(); + const childLevel = level + '.' + (subArticles.size + 1); + + return childLevel; +}; + +/** + * Is article pointing to a page of an absolute url + * + * @return {Boolean} + */ +SummaryArticle.prototype.isPage = function() { + return !this.isExternal() && this.getRef(); +}; + +/** + * Check if this article is a file (exatcly) + * + * @param {File} file + * @return {Boolean} + */ +SummaryArticle.prototype.isFile = function(file) { + return ( + file.getPath() === this.getPath() + && this.getAnchor() === undefined + ); +}; + +/** + * Check if this article is the introduction of the book + * + * @param {Book|Readme} book + * @return {Boolean} + */ +SummaryArticle.prototype.isReadme = function(book) { + const readme = book.getFile ? book : book.getReadme(); + const file = readme.getFile(); + + return this.isFile(file); +}; + +/** + * Is article pointing to aan absolute url + * + * @return {Boolean} + */ +SummaryArticle.prototype.isExternal = function() { + return location.isExternal(this.getRef()); +}; + +/** + * Create a SummaryArticle + * + * @param {Object} def + * @return {SummaryArticle} + */ +SummaryArticle.create = function(def, level) { + const articles = (def.articles || []).map(function(article, i) { + if (article instanceof SummaryArticle) { + return article; + } + return SummaryArticle.create(article, [level, i + 1].join('.')); + }); + + return new SummaryArticle({ + level, + title: def.title, + ref: def.ref || def.path || '', + articles: Immutable.List(articles) + }); +}; + +/** + * Find an article from a base one + * + * @param {Article|Part} base + * @param {Function(article)} iter + * @return {Article} + */ +SummaryArticle.findArticle = function(base, iter) { + const articles = base.getArticles(); + + return articles.reduce(function(result, article) { + if (result) return result; + + if (iter(article)) { + return article; + } + + return SummaryArticle.findArticle(article, iter); + }, null); +}; + + +module.exports = SummaryArticle; diff --git a/packages/gitbook/src/models/summaryPart.js b/packages/gitbook/src/models/summaryPart.js new file mode 100644 index 0000000..0bb5369 --- /dev/null +++ b/packages/gitbook/src/models/summaryPart.js @@ -0,0 +1,61 @@ +const Immutable = require('immutable'); + +const SummaryArticle = require('./summaryArticle'); + +/* + A part represents a section in the Summary / table of Contents +*/ + +const SummaryPart = Immutable.Record({ + level: String(), + title: String(), + articles: Immutable.List() +}); + +SummaryPart.prototype.getLevel = function() { + return this.get('level'); +}; + +SummaryPart.prototype.getTitle = function() { + return this.get('title'); +}; + +SummaryPart.prototype.getArticles = function() { + return this.get('articles'); +}; + +/** + * Create a new level for a new child article + * + * @return {String} + */ +SummaryPart.prototype.createChildLevel = function() { + const level = this.getLevel(); + const subArticles = this.getArticles(); + const childLevel = level + '.' + (subArticles.size + 1); + + return childLevel; +}; + +/** + * Create a SummaryPart + * + * @param {Object} def + * @return {SummaryPart} + */ +SummaryPart.create = function(def, level) { + const articles = (def.articles || []).map(function(article, i) { + if (article instanceof SummaryArticle) { + return article; + } + return SummaryArticle.create(article, [level, i + 1].join('.')); + }); + + return new SummaryPart({ + level: String(level), + title: def.title, + articles: Immutable.List(articles) + }); +}; + +module.exports = SummaryPart; diff --git a/packages/gitbook/src/models/templateBlock.js b/packages/gitbook/src/models/templateBlock.js new file mode 100644 index 0000000..61c006f --- /dev/null +++ b/packages/gitbook/src/models/templateBlock.js @@ -0,0 +1,253 @@ +const is = require('is'); +const extend = require('extend'); +const { Record, List, Map } = require('immutable'); +const escape = require('escape-html'); + +const Promise = require('../utils/promise'); +const TemplateShortcut = require('./templateShortcut'); + +const NODE_ENDARGS = '%%endargs%%'; +const HTML_TAGNAME = 'xblock'; + +const DEFAULTS = { + // Name of block, also the start tag + name: String(), + // End tag, default to "end<name>" + end: String(), + // Function to process the block content + process: Function(), + // List of String, for inner block tags + blocks: List(), + // List of shortcuts to replace with this block + shortcuts: Map() +}; + +class TemplateBlock extends Record(DEFAULTS) { + getName() { + return this.get('name'); + } + + getEndTag() { + return this.get('end') || ('end' + this.getName()); + } + + getProcess() { + return this.get('process'); + } + + getBlocks() { + return this.get('blocks'); + } + + + /** + * Return shortcuts associated with this block or undefined + * @return {TemplateShortcut|undefined} + */ + getShortcuts() { + const shortcuts = this.get('shortcuts'); + if (shortcuts.size === 0) { + return undefined; + } + + return TemplateShortcut.createForBlock(this, shortcuts); + } + + /** + * Return name for the nunjucks extension + * @return {String} + */ + getExtensionName() { + return 'Block' + this.getName() + 'Extension'; + } + + /** + * Return a nunjucks extension to represents this block + * @return {Nunjucks.Extension} + */ + toNunjucksExt(mainContext = {}) { + const that = this; + const name = this.getName(); + const endTag = this.getEndTag(); + const blocks = this.getBlocks().toJS(); + + function Ext() { + this.tags = [name]; + + this.parse = (parser, nodes) => { + let lastBlockName = null; + let lastBlockArgs = null; + const allBlocks = blocks.concat([endTag]); + + // Parse first block + const tok = parser.nextToken(); + lastBlockArgs = parser.parseSignature(null, true); + parser.advanceAfterBlockEnd(tok.value); + + const args = new nodes.NodeList(); + const bodies = []; + const blockNamesNode = new nodes.Array(tok.lineno, tok.colno); + const blockArgCounts = new nodes.Array(tok.lineno, tok.colno); + + // Parse while we found "end<block>" + do { + // Read body + const currentBody = parser.parseUntilBlocks(...allBlocks); + + // Handle body with previous block name and args + blockNamesNode.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockName)); + blockArgCounts.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockArgs.children.length)); + bodies.push(currentBody); + + // Append arguments of this block as arguments of the run function + lastBlockArgs.children.forEach(function(child) { + args.addChild(child); + }); + + // Read new block + lastBlockName = parser.nextToken().value; + + // Parse signature and move to the end of the block + if (lastBlockName != endTag) { + lastBlockArgs = parser.parseSignature(null, true); + } + + parser.advanceAfterBlockEnd(lastBlockName); + } while (lastBlockName != endTag); + + args.addChild(blockNamesNode); + args.addChild(blockArgCounts); + args.addChild(new nodes.Literal(args.lineno, args.colno, NODE_ENDARGS)); + + return new nodes.CallExtensionAsync(this, 'run', args, bodies); + }; + + this.run = (context, ...fnArgs) => { + let args; + const blocks = []; + let bodies = []; + + // Extract callback + const callback = fnArgs.pop(); + + // Detect end of arguments + const endArgIndex = fnArgs.indexOf(NODE_ENDARGS); + + // Extract arguments and bodies + args = fnArgs.slice(0, endArgIndex); + bodies = fnArgs.slice(endArgIndex + 1); + + // Extract block counts + const blockArgCounts = args.pop(); + const blockNames = args.pop(); + + // Recreate list of blocks + blockNames.forEach((blkName, i) => { + const countArgs = blockArgCounts[i]; + const blockBody = bodies.shift(); + + const blockArgs = countArgs > 0 ? args.slice(0, countArgs) : []; + args = args.slice(countArgs); + const blockKwargs = extractKwargs(blockArgs); + + blocks.push({ + name: blkName, + children: blockBody(), + args: blockArgs, + kwargs: blockKwargs + }); + }); + + const mainBlock = blocks.shift(); + mainBlock.blocks = blocks; + + Promise() + .then(function() { + const ctx = extend({ + ctx: context + }, mainContext); + + return that.toProps(mainBlock, ctx); + }) + .then(function(props) { + return that.toHTML(props); + }) + .nodeify(callback); + }; + } + + return Ext; + } + + /** + * Apply a block an return the props + * + * @param {Object} inner + * @param {Object} context + * @return {Promise<Props>} + */ + toProps(inner, context) { + const processFn = this.getProcess(); + + inner = inner || {}; + inner.args = inner.args || []; + inner.kwargs = inner.kwargs || {}; + inner.blocks = inner.blocks || []; + + return Promise() + .then(() => processFn.call(context, inner)) + .then(props => { + if (is.string(props)) { + return { children: props }; + } + + return props; + }); + } + + /** + * Convert a block props to HTML. This HTML is then being + * parsed by gitbook-core during rendering, and binded to the right react components. + * + * @param {Object} props + * @return {String} + */ + toHTML(props) { + const { children, ...innerProps } = props; + const payload = escape(JSON.stringify(innerProps)); + + return ( + `<${HTML_TAGNAME} name="${this.name}" props="${payload}">${children || ''}</${HTML_TAGNAME}>` + ); + } + + /** + * Create a template block from a function or an object + * @param {String} blockName + * @param {Object} block + * @return {TemplateBlock} + */ + static create(blockName, block) { + if (is.fn(block)) { + block = new Map({ + process: block + }); + } + + block = new TemplateBlock(block); + block = block.set('name', blockName); + return block; + } +} + +/** + * Extract kwargs from an arguments array + * @param {Array} args + * @return {Object} + */ +function extractKwargs(args) { + const last = args[args.length - 1]; + return (is.object(last) && last.__keywords) ? args.pop() : {}; +} + +module.exports = TemplateBlock; diff --git a/packages/gitbook/src/models/templateEngine.js b/packages/gitbook/src/models/templateEngine.js new file mode 100644 index 0000000..0d0dcb6 --- /dev/null +++ b/packages/gitbook/src/models/templateEngine.js @@ -0,0 +1,133 @@ +const nunjucks = require('nunjucks'); +const { Record, Map, List } = require('immutable'); + +const DEFAULTS = { + // List of {TemplateBlock} + blocks: List(), + // Map of Extension + extensions: Map(), + // Map of filters: {String} name -> {Function} fn + filters: Map(), + // Map of globals: {String} name -> {Mixed} + globals: Map(), + // Context for filters / blocks + context: Object(), + // Nunjucks loader + loader: nunjucks.FileSystemLoader('views') +}; + +class TemplateEngine extends Record(DEFAULTS) { + getBlocks() { + return this.get('blocks'); + } + + getGlobals() { + return this.get('globals'); + } + + getFilters() { + return this.get('filters'); + } + + getShortcuts() { + return this.get('shortcuts'); + } + + getLoader() { + return this.get('loader'); + } + + getContext() { + return this.get('context'); + } + + getExtensions() { + return this.get('extensions'); + } + + /** + * Return a block by its name (or undefined). + * @param {String} name + * @return {TemplateBlock} block? + */ + getBlock(name) { + const blocks = this.getBlocks(); + return blocks.find(function(block) { + return block.getName() === name; + }); + } + + /** + * Return a nunjucks environment from this configuration + * @return {Nunjucks.Environment} env + */ + toNunjucks() { + const loader = this.getLoader(); + const blocks = this.getBlocks(); + const filters = this.getFilters(); + const globals = this.getGlobals(); + const extensions = this.getExtensions(); + const context = this.getContext(); + + const env = new nunjucks.Environment( + loader, + { + // Escaping is done after by the asciidoc/markdown parser + autoescape: false, + + // Syntax + tags: { + blockStart: '{%', + blockEnd: '%}', + variableStart: '{{', + variableEnd: '}}', + commentStart: '{###', + commentEnd: '###}' + } + } + ); + + // Add filters + filters.forEach(function(filterFn, filterName) { + env.addFilter(filterName, filterFn.bind(context)); + }); + + // Add blocks + blocks.forEach(function(block) { + const extName = block.getExtensionName(); + const Ext = block.toNunjucksExt(context); + + env.addExtension(extName, new Ext()); + }); + + // Add globals + globals.forEach(function(globalValue, globalName) { + env.addGlobal(globalName, globalValue); + }); + + // Add other extensions + extensions.forEach(function(ext, extName) { + env.addExtension(extName, ext); + }); + + return env; + } + + /** + * Create a template engine. + * @param {Object} def + * @return {TemplateEngine} engine + */ + static create(def) { + return new TemplateEngine({ + blocks: List(def.blocks || []), + extensions: Map(def.extensions || {}), + filters: Map(def.filters || {}), + globals: Map(def.globals || {}), + context: def.context, + loader: def.loader + }); + } +} + +module.exports = TemplateEngine; diff --git a/packages/gitbook/src/models/templateShortcut.js b/packages/gitbook/src/models/templateShortcut.js new file mode 100644 index 0000000..b6e1ed9 --- /dev/null +++ b/packages/gitbook/src/models/templateShortcut.js @@ -0,0 +1,73 @@ +const Immutable = require('immutable'); +const is = require('is'); + +/* + A TemplateShortcut is defined in plugin's template blocks + to replace content with a templating block using delimiters. +*/ +const TemplateShortcut = Immutable.Record({ + // List of parser names accepting this shortcut + parsers: Immutable.Map(), + + start: String(), + end: String(), + + startTag: String(), + endTag: String() +}, 'TemplateShortcut'); + +TemplateShortcut.prototype.getStart = function() { + return this.get('start'); +}; + +TemplateShortcut.prototype.getEnd = function() { + return this.get('end'); +}; + +TemplateShortcut.prototype.getStartTag = function() { + return this.get('startTag'); +}; + +TemplateShortcut.prototype.getEndTag = function() { + return this.get('endTag'); +}; + +TemplateShortcut.prototype.getParsers = function() { + return this.get('parsers'); +}; + +/** + Test if this shortcut accept a parser + + @param {Parser|String} parser + @return {Boolean} +*/ +TemplateShortcut.prototype.acceptParser = function(parser) { + if (!is.string(parser)) { + parser = parser.getName(); + } + + const parserNames = this.get('parsers'); + return parserNames.includes(parser); +}; + +/** + Create a shortcut for a block + + @param {TemplateBlock} block + @param {Map} details + @return {TemplateShortcut} +*/ +TemplateShortcut.createForBlock = function(block, details) { + details = Immutable.fromJS(details); + + return new TemplateShortcut({ + parsers: details.get('parsers'), + start: details.get('start'), + end: details.get('end'), + startTag: block.getName(), + endTag: block.getEndTag() + }); +}; + +module.exports = TemplateShortcut; diff --git a/packages/gitbook/src/models/uriIndex.js b/packages/gitbook/src/models/uriIndex.js new file mode 100644 index 0000000..eecdc54 --- /dev/null +++ b/packages/gitbook/src/models/uriIndex.js @@ -0,0 +1,159 @@ +const path = require('path'); +const url = require('url'); +const { Record, Map } = require('immutable'); +const LocationUtils = require('../utils/location'); + +/* + The URIIndex stores a map of filename to url. + To resolve urls for each article. + */ + +const DEFAULTS = { + uris: Map(), + directoryIndex: Boolean(true) +}; + +/** + * Modify an url path while preserving the hash + * @param {String} input + * @param {Function<String>} transform + * @return {String} output + */ +function transformURLPath(input, transform) { + // Split anchor + const parsed = url.parse(input); + input = parsed.pathname || ''; + + input = transform(input); + + // Add back anchor + input = input + (parsed.hash || ''); + + return input; +} + +class URIIndex extends Record(DEFAULTS) { + constructor(index) { + super({ + uris: Map(index) + .mapKeys(key => LocationUtils.normalize(key)) + }); + } + + /** + * Append a file to the index + * @param {String} filePath + * @param {String} url + * @return {URIIndex} + */ + append(filePath, uri) { + const { uris } = this; + filePath = LocationUtils.normalize(filePath); + + return this.merge({ + uris: uris.set(filePath, uri) + }); + } + + /** + * Resolve an absolute file path to an url. + * + * @param {String} filePath + * @return {String} url + */ + resolve(filePath) { + if (LocationUtils.isExternal(filePath)) { + return filePath; + } + + return transformURLPath(filePath, (href) => { + const { uris } = this; + href = LocationUtils.normalize(href); + + return uris.get(href, href); + }); + } + + /** + * Resolve a filename to an url, considering that the link to "filePath" + * in the file "originPath". + * + * For example if we are generating doc/README.md and we have a link "/READNE.md": + * index.resolveFrom('doc/README.md', '/README.md') === '../index.html' + * + * @param {String} originPath + * @param {String} filePath + * @return {String} url + */ + resolveFrom(originPath, filePath) { + if (LocationUtils.isExternal(filePath)) { + return filePath; + } + + const originURL = this.resolve(originPath); + const originDir = path.dirname(originPath); + const originOutDir = path.dirname(originURL); + + return transformURLPath(filePath, (href) => { + if (!href) { + return href; + } + // Calcul absolute path for this + href = LocationUtils.toAbsolute(href, originDir, '.'); + + // Resolve file + href = this.resolve(href); + + // Convert back to relative + href = LocationUtils.relative(originOutDir, href); + + return href; + }); + } + + /** + * Normalize an url + * @param {String} uri + * @return {String} uri + */ + normalizeURL(uri) { + const { directoryIndex } = this; + + if (!directoryIndex || LocationUtils.isExternal(uri)) { + return uri; + } + + return transformURLPath(uri, (pathname) => { + if (path.basename(pathname) == 'index.html') { + pathname = path.dirname(pathname) + '/'; + } + + return pathname; + }); + } + + /** + * Resolve an entry to an url + * @param {String} filePath + * @return {String} + */ + resolveToURL(filePath) { + const uri = this.resolve(filePath); + return this.normalizeURL(uri); + } + + /** + * Resolve an entry to an url + * + * @param {String} originPath + * @param {String} filePath + * @return {String} url + */ + resolveToURLFrom(originPath, filePath) { + const uri = this.resolveFrom(originPath, filePath); + return this.normalizeURL(uri); + } + +} + +module.exports = URIIndex; diff --git a/packages/gitbook/src/modifiers/config/__tests__/addPlugin.js b/packages/gitbook/src/modifiers/config/__tests__/addPlugin.js new file mode 100644 index 0000000..65fd8f9 --- /dev/null +++ b/packages/gitbook/src/modifiers/config/__tests__/addPlugin.js @@ -0,0 +1,31 @@ +const addPlugin = require('../addPlugin'); +const Config = require('../../../models/config'); + +describe('addPlugin', function() { + const config = Config.createWithValues({ + plugins: ['hello', 'world', '-disabled'] + }); + + it('should have correct state of dependencies', function() { + const disabledDep = config.getPluginDependency('disabled'); + + expect(disabledDep).toBeDefined(); + expect(disabledDep.getVersion()).toEqual('*'); + expect(disabledDep.isEnabled()).toBeFalsy(); + }); + + it('should add the plugin to the list', function() { + const newConfig = addPlugin(config, 'test'); + + const testDep = newConfig.getPluginDependency('test'); + expect(testDep).toBeDefined(); + expect(testDep.getVersion()).toEqual('*'); + expect(testDep.isEnabled()).toBeTruthy(); + + const disabledDep = newConfig.getPluginDependency('disabled'); + expect(disabledDep).toBeDefined(); + expect(disabledDep.getVersion()).toEqual('*'); + expect(disabledDep.isEnabled()).toBeFalsy(); + }); +}); + diff --git a/packages/gitbook/src/modifiers/config/__tests__/removePlugin.js b/packages/gitbook/src/modifiers/config/__tests__/removePlugin.js new file mode 100644 index 0000000..5450b30 --- /dev/null +++ b/packages/gitbook/src/modifiers/config/__tests__/removePlugin.js @@ -0,0 +1,32 @@ +const removePlugin = require('../removePlugin'); +const Config = require('../../../models/config'); + +describe('removePlugin', function() { + const config = Config.createWithValues({ + plugins: ['hello', 'world', '-disabled'] + }); + + it('should remove the plugin from the list', function() { + const newConfig = removePlugin(config, 'hello'); + + const testDep = newConfig.getPluginDependency('hello'); + expect(testDep).toNotBeDefined(); + }); + + it('should remove the disabled plugin from the list', function() { + const newConfig = removePlugin(config, 'disabled'); + + const testDep = newConfig.getPluginDependency('disabled'); + expect(testDep).toNotBeDefined(); + }); + + it('should disable default plugin', function() { + const newConfig = removePlugin(config, 'search'); + + const disabledDep = newConfig.getPluginDependency('search'); + expect(disabledDep).toBeDefined(); + expect(disabledDep.getVersion()).toEqual('*'); + expect(disabledDep.isEnabled()).toBeFalsy(); + }); +}); + diff --git a/packages/gitbook/src/modifiers/config/__tests__/togglePlugin.js b/packages/gitbook/src/modifiers/config/__tests__/togglePlugin.js new file mode 100644 index 0000000..6d23ae0 --- /dev/null +++ b/packages/gitbook/src/modifiers/config/__tests__/togglePlugin.js @@ -0,0 +1,27 @@ +const togglePlugin = require('../togglePlugin'); +const Config = require('../../../models/config'); + +describe('togglePlugin', function() { + const config = Config.createWithValues({ + plugins: ['hello', 'world', '-disabled'] + }); + + it('should enable plugin', function() { + const newConfig = togglePlugin(config, 'disabled'); + + const testDep = newConfig.getPluginDependency('disabled'); + expect(testDep).toBeDefined(); + expect(testDep.getVersion()).toEqual('*'); + expect(testDep.isEnabled()).toBeTruthy(); + }); + + it('should disable plugin', function() { + const newConfig = togglePlugin(config, 'world'); + + const testDep = newConfig.getPluginDependency('world'); + expect(testDep).toBeDefined(); + expect(testDep.getVersion()).toEqual('*'); + expect(testDep.isEnabled()).toBeFalsy(); + }); +}); + diff --git a/packages/gitbook/src/modifiers/config/addPlugin.js b/packages/gitbook/src/modifiers/config/addPlugin.js new file mode 100644 index 0000000..e9ed259 --- /dev/null +++ b/packages/gitbook/src/modifiers/config/addPlugin.js @@ -0,0 +1,25 @@ +const PluginDependency = require('../../models/pluginDependency'); +const togglePlugin = require('./togglePlugin'); +const isDefaultPlugin = require('./isDefaultPlugin'); + +/** + * Add a plugin to a book's configuration + * @param {Config} config + * @param {String} pluginName + * @param {String} version (optional) + * @return {Config} + */ +function addPlugin(config, pluginName, version) { + // For default plugin, we only ensure it is enabled + if (isDefaultPlugin(pluginName, version)) { + return togglePlugin(config, pluginName, true); + } + + let deps = config.getPluginDependencies(); + const dep = PluginDependency.create(pluginName, version); + + deps = deps.push(dep); + return config.setPluginDependencies(deps); +} + +module.exports = addPlugin; diff --git a/packages/gitbook/src/modifiers/config/editPlugin.js b/packages/gitbook/src/modifiers/config/editPlugin.js new file mode 100644 index 0000000..dd7fd11 --- /dev/null +++ b/packages/gitbook/src/modifiers/config/editPlugin.js @@ -0,0 +1,13 @@ + +/** + * Edit configuration of a plugin + * @param {Config} config + * @param {String} plugin + * @param {Object} pluginConfig + * @return {Config} + */ +function editPlugin(config, pluginName, pluginConfig) { + return config.setValue('pluginsConfig.' + pluginName, pluginConfig); +} + +module.exports = editPlugin; diff --git a/packages/gitbook/src/modifiers/config/getPluginConfig.js b/packages/gitbook/src/modifiers/config/getPluginConfig.js new file mode 100644 index 0000000..ed7d6ea --- /dev/null +++ b/packages/gitbook/src/modifiers/config/getPluginConfig.js @@ -0,0 +1,20 @@ +/** + * Return the configuration for a plugin + * @param {Config} config + * @param {String} pluginName + * @return {Object} + */ +function getPluginConfig(config, pluginName) { + const pluginsConfig = config.getValues().get('pluginsConfig'); + if (pluginsConfig === undefined) { + return {}; + } + const pluginConf = pluginsConfig.get(pluginName); + if (pluginConf === undefined) { + return {}; + } else { + return pluginConf.toJS(); + } +} + +module.exports = getPluginConfig; diff --git a/packages/gitbook/src/modifiers/config/hasPlugin.js b/packages/gitbook/src/modifiers/config/hasPlugin.js new file mode 100644 index 0000000..9aab4f2 --- /dev/null +++ b/packages/gitbook/src/modifiers/config/hasPlugin.js @@ -0,0 +1,15 @@ + +/** + * Test if a plugin is listed + * @param { {List<PluginDependency}} deps + * @param {String} plugin + * @param {String} version + * @return {Boolean} + */ +function hasPlugin(deps, pluginName, version) { + return !!deps.find(function(dep) { + return dep.getName() === pluginName && (!version || dep.getVersion() === version); + }); +} + +module.exports = hasPlugin; diff --git a/packages/gitbook/src/modifiers/config/index.js b/packages/gitbook/src/modifiers/config/index.js new file mode 100644 index 0000000..b3de0b0 --- /dev/null +++ b/packages/gitbook/src/modifiers/config/index.js @@ -0,0 +1,10 @@ + +module.exports = { + addPlugin: require('./addPlugin'), + removePlugin: require('./removePlugin'), + togglePlugin: require('./togglePlugin'), + editPlugin: require('./editPlugin'), + hasPlugin: require('./hasPlugin'), + getPluginConfig: require('./getPluginConfig'), + isDefaultPlugin: require('./isDefaultPlugin') +}; diff --git a/packages/gitbook/src/modifiers/config/isDefaultPlugin.js b/packages/gitbook/src/modifiers/config/isDefaultPlugin.js new file mode 100644 index 0000000..096e21a --- /dev/null +++ b/packages/gitbook/src/modifiers/config/isDefaultPlugin.js @@ -0,0 +1,14 @@ +const DEFAULT_PLUGINS = require('../../constants/defaultPlugins'); +const hasPlugin = require('./hasPlugin'); + +/** + * Test if a plugin is a default one + * @param {String} plugin + * @param {String} version + * @return {Boolean} + */ +function isDefaultPlugin(pluginName, version) { + return hasPlugin(DEFAULT_PLUGINS, pluginName, version); +} + +module.exports = isDefaultPlugin; diff --git a/packages/gitbook/src/modifiers/config/removePlugin.js b/packages/gitbook/src/modifiers/config/removePlugin.js new file mode 100644 index 0000000..c80ab84 --- /dev/null +++ b/packages/gitbook/src/modifiers/config/removePlugin.js @@ -0,0 +1,25 @@ +const togglePlugin = require('./togglePlugin'); +const isDefaultPlugin = require('./isDefaultPlugin'); + +/** + * Remove a plugin from a book's configuration + * @param {Config} config + * @param {String} plugin + * @return {Config} + */ +function removePlugin(config, pluginName) { + let deps = config.getPluginDependencies(); + + // For default plugin, we have to disable it instead of removing from the list + if (isDefaultPlugin(pluginName)) { + return togglePlugin(config, pluginName, false); + } + + // Remove the dependency from the list + deps = deps.filterNot(function(dep) { + return dep.getName() === pluginName; + }); + return config.setPluginDependencies(deps); +} + +module.exports = removePlugin; diff --git a/packages/gitbook/src/modifiers/config/togglePlugin.js b/packages/gitbook/src/modifiers/config/togglePlugin.js new file mode 100644 index 0000000..12a6dec --- /dev/null +++ b/packages/gitbook/src/modifiers/config/togglePlugin.js @@ -0,0 +1,31 @@ +const PluginDependency = require('../../models/pluginDependency'); +const hasPlugin = require('./hasPlugin'); +const isDefaultPlugin = require('./isDefaultPlugin'); + +/** + * Enable/disable a plugin dependency + * @param {Config} config + * @param {String} pluginName + * @param {Boolean} state (optional) + * @return {Config} + */ +function togglePlugin(config, pluginName, state) { + let deps = config.getPluginDependencies(); + + // For default plugin, we should ensure it's listed first + if (isDefaultPlugin(pluginName) && !hasPlugin(deps, pluginName)) { + deps = deps.push(PluginDependency.create(pluginName)); + } + + deps = deps.map(function(dep) { + if (dep.getName() === pluginName) { + return dep.toggle(state); + } + + return dep; + }); + + return config.setPluginDependencies(deps); +} + +module.exports = togglePlugin; diff --git a/packages/gitbook/src/modifiers/index.js b/packages/gitbook/src/modifiers/index.js new file mode 100644 index 0000000..ad24604 --- /dev/null +++ b/packages/gitbook/src/modifiers/index.js @@ -0,0 +1,5 @@ + +module.exports = { + Summary: require('./summary'), + Config: require('./config') +}; diff --git a/packages/gitbook/src/modifiers/summary/__tests__/editArticle.js b/packages/gitbook/src/modifiers/summary/__tests__/editArticle.js new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/__tests__/editArticle.js diff --git a/packages/gitbook/src/modifiers/summary/__tests__/editPartTitle.js b/packages/gitbook/src/modifiers/summary/__tests__/editPartTitle.js new file mode 100644 index 0000000..aa14a34 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/__tests__/editPartTitle.js @@ -0,0 +1,43 @@ +const Summary = require('../../../models/summary'); +const File = require('../../../models/file'); + +describe('editPartTitle', function() { + const editPartTitle = require('../editPartTitle'); + const summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: 'My First Article', + path: 'README.md' + }, + { + title: 'My Second Article', + path: 'article.md' + } + ] + }, + { + title: 'Test' + } + ]); + + it('should correctly set title of first part', function() { + const newSummary = editPartTitle(summary, 0, 'Hello World'); + const part = newSummary.getPart(0); + + expect(part.getTitle()).toBe('Hello World'); + }); + + it('should correctly set title of second part', function() { + const newSummary = editPartTitle(summary, 1, 'Hello'); + const part = newSummary.getPart(1); + + expect(part.getTitle()).toBe('Hello'); + }); + + it('should not fail if part doesn\'t exist', function() { + const newSummary = editPartTitle(summary, 3, 'Hello'); + expect(newSummary.getParts().size).toBe(2); + }); +}); + diff --git a/packages/gitbook/src/modifiers/summary/__tests__/insertArticle.js b/packages/gitbook/src/modifiers/summary/__tests__/insertArticle.js new file mode 100644 index 0000000..d5ae9bc --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/__tests__/insertArticle.js @@ -0,0 +1,78 @@ +const Summary = require('../../../models/summary'); +const SummaryArticle = require('../../../models/summaryArticle'); +const File = require('../../../models/file'); + +describe('insertArticle', function() { + const insertArticle = require('../insertArticle'); + const summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [ + { + title: '2.1.1', + path: '2.1.1' + }, + { + title: '2.1.2', + path: '2.1.2' + } + ] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('should insert an article at a given level', function() { + const article = SummaryArticle.create({ + title: 'Inserted' + }, 'fake.level'); + + const newSummary = insertArticle(summary, article, '2.1.1'); + + const inserted = newSummary.getByLevel('2.1.1'); + const nextOne = newSummary.getByLevel('2.1.2'); + + expect(inserted.getTitle()).toBe('Inserted'); + expect(inserted.getLevel()).toBe('2.1.1'); + + expect(nextOne.getTitle()).toBe('2.1.1'); + expect(nextOne.getLevel()).toBe('2.1.2'); + }); + + it('should insert an article in last position', function() { + const article = SummaryArticle.create({ + title: 'Inserted' + }, 'fake.level'); + + const newSummary = insertArticle(summary, article, '2.2'); + + const inserted = newSummary.getByLevel('2.2'); + const previousOne = newSummary.getByLevel('2.1'); + + expect(inserted.getTitle()).toBe('Inserted'); + expect(inserted.getLevel()).toBe('2.2'); + + expect(previousOne.getTitle()).toBe('2.1'); // Unchanged + expect(previousOne.getLevel()).toBe('2.1'); + }); +}); diff --git a/packages/gitbook/src/modifiers/summary/__tests__/insertPart.js b/packages/gitbook/src/modifiers/summary/__tests__/insertPart.js new file mode 100644 index 0000000..5112931 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/__tests__/insertPart.js @@ -0,0 +1,60 @@ +const Summary = require('../../../models/summary'); +const SummaryPart = require('../../../models/summaryPart'); + +const File = require('../../../models/file'); + +describe('insertPart', function() { + const insertPart = require('../insertPart'); + const summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('should insert an part at a given level', function() { + const part = SummaryPart.create({ + title: 'Inserted' + }, 'meaningless.level'); + + const newSummary = insertPart(summary, part, 1); + + const inserted = newSummary.getPart(1); + expect(inserted.getTitle()).toBe('Inserted'); + expect(newSummary.getParts().count()).toBe(3); + + const otherArticle = newSummary.getByLevel('3.1'); + expect(otherArticle.getTitle()).toBe('2.1'); + expect(otherArticle.getLevel()).toBe('3.1'); + }); + + it('should insert an part in last position', function() { + const part = SummaryPart.create({ + title: 'Inserted' + }, 'meaningless.level'); + + const newSummary = insertPart(summary, part, 2); + + const inserted = newSummary.getPart(2); + expect(inserted.getTitle()).toBe('Inserted'); + expect(newSummary.getParts().count()).toBe(3); + }); +}); diff --git a/packages/gitbook/src/modifiers/summary/__tests__/mergeAtLevel.js b/packages/gitbook/src/modifiers/summary/__tests__/mergeAtLevel.js new file mode 100644 index 0000000..e0d4a62 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/__tests__/mergeAtLevel.js @@ -0,0 +1,45 @@ +const Immutable = require('immutable'); +const Summary = require('../../../models/summary'); +const File = require('../../../models/file'); + +describe('mergeAtLevel', function() { + const mergeAtLevel = require('../mergeAtLevel'); + const summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [] + } + ]); + + it('should edit a part', function() { + const beforeChildren = summary.getByLevel('1').getArticles(); + const newSummary = mergeAtLevel(summary, '1', {title: 'Part O'}); + const edited = newSummary.getByLevel('1'); + + expect(edited.getTitle()).toBe('Part O'); + // Same children + expect(Immutable.is(beforeChildren, edited.getArticles())).toBe(true); + }); + + it('should edit a part', function() { + const beforePath = summary.getByLevel('1.2').getPath(); + const newSummary = mergeAtLevel(summary, '1.2', {title: 'Renamed article'}); + const edited = newSummary.getByLevel('1.2'); + + expect(edited.getTitle()).toBe('Renamed article'); + // Same children + expect(Immutable.is(beforePath, edited.getPath())).toBe(true); + }); +}); diff --git a/packages/gitbook/src/modifiers/summary/__tests__/moveArticle.js b/packages/gitbook/src/modifiers/summary/__tests__/moveArticle.js new file mode 100644 index 0000000..a7d111b --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/__tests__/moveArticle.js @@ -0,0 +1,68 @@ +const Immutable = require('immutable'); +const Summary = require('../../../models/summary'); +const File = require('../../../models/file'); + +describe('moveArticle', function() { + const moveArticle = require('../moveArticle'); + const summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [ + { + title: '2.1.1', + path: '2.1.1' + }, + { + title: '2.1.2', + path: '2.1.2' + } + ] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('should move an article to the same place', function() { + const newSummary = moveArticle(summary, '2.1', '2.1'); + + expect(Immutable.is(summary, newSummary)).toBe(true); + }); + + it('should move an article to an previous level', function() { + const newSummary = moveArticle(summary, '2.2', '2.1'); + const moved = newSummary.getByLevel('2.1'); + const other = newSummary.getByLevel('2.2'); + + expect(moved.getTitle()).toBe('2.2'); + expect(other.getTitle()).toBe('2.1'); + }); + + it('should move an article to a next level', function() { + const newSummary = moveArticle(summary, '2.1', '2.2'); + const moved = newSummary.getByLevel('2.1'); + const other = newSummary.getByLevel('2.2'); + + expect(moved.getTitle()).toBe('2.2'); + expect(other.getTitle()).toBe('2.1'); + }); +}); diff --git a/packages/gitbook/src/modifiers/summary/__tests__/moveArticleAfter.js b/packages/gitbook/src/modifiers/summary/__tests__/moveArticleAfter.js new file mode 100644 index 0000000..446d8a4 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/__tests__/moveArticleAfter.js @@ -0,0 +1,82 @@ +const Immutable = require('immutable'); +const Summary = require('../../../models/summary'); +const File = require('../../../models/file'); + +describe('moveArticleAfter', function() { + const moveArticleAfter = require('../moveArticleAfter'); + const summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [ + { + title: '2.1.1', + path: '2.1.1' + }, + { + title: '2.1.2', + path: '2.1.2' + } + ] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('moving right after itself should be invariant', function() { + const newSummary = moveArticleAfter(summary, '2.1', '2.1'); + + expect(Immutable.is(summary, newSummary)).toBe(true); + }); + + it('moving after previous one should be invariant too', function() { + const newSummary = moveArticleAfter(summary, '2.1', '2.0'); + + expect(Immutable.is(summary, newSummary)).toBe(true); + }); + + it('should move an article after a previous level', function() { + const newSummary = moveArticleAfter(summary, '2.2', '2.0'); + const moved = newSummary.getByLevel('2.1'); + + expect(moved.getTitle()).toBe('2.2'); + expect(newSummary.getByLevel('2.2').getTitle()).toBe('2.1'); + }); + + it('should move an article after a previous and less deep level', function() { + const newSummary = moveArticleAfter(summary, '2.1.1', '2.0'); + const moved = newSummary.getByLevel('2.1'); + + expect(moved.getTitle()).toBe('2.1.1'); + expect(newSummary.getByLevel('2.2.1').getTitle()).toBe('2.1.2'); + expect(newSummary.getByLevel('2.2').getTitle()).toBe('2.1'); + }); + + it('should move an article after a next level', function() { + const newSummary = moveArticleAfter(summary, '2.1', '2.2'); + const moved = newSummary.getByLevel('2.2'); + + expect(moved.getTitle()).toBe('2.1'); + expect(newSummary.getByLevel('2.1').getTitle()).toBe('2.2'); + }); + +}); diff --git a/packages/gitbook/src/modifiers/summary/__tests__/removeArticle.js b/packages/gitbook/src/modifiers/summary/__tests__/removeArticle.js new file mode 100644 index 0000000..14587ca --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/__tests__/removeArticle.js @@ -0,0 +1,53 @@ +const Summary = require('../../../models/summary'); +const File = require('../../../models/file'); + +describe('removeArticle', function() { + const removeArticle = require('../removeArticle'); + const summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [ + { + title: '2.1.1', + path: '2.1.1' + }, + { + title: '2.1.2', + path: '2.1.2' + } + ] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('should remove an article at a given level', function() { + const newSummary = removeArticle(summary, '2.1.1'); + + const removed = newSummary.getByLevel('2.1.1'); + const nextOne = newSummary.getByLevel('2.1.2'); + + expect(removed.getTitle()).toBe('2.1.2'); + expect(nextOne).toBe(null); + }); +}); diff --git a/packages/gitbook/src/modifiers/summary/editArticleRef.js b/packages/gitbook/src/modifiers/summary/editArticleRef.js new file mode 100644 index 0000000..c5c1868 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/editArticleRef.js @@ -0,0 +1,17 @@ +const mergeAtLevel = require('./mergeAtLevel'); + +/** + Edit the ref of an article + + @param {Summary} summary + @param {String} level + @param {String} newRef + @return {Summary} +*/ +function editArticleRef(summary, level, newRef) { + return mergeAtLevel(summary, level, { + ref: newRef + }); +} + +module.exports = editArticleRef; diff --git a/packages/gitbook/src/modifiers/summary/editArticleTitle.js b/packages/gitbook/src/modifiers/summary/editArticleTitle.js new file mode 100644 index 0000000..f55c97e --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/editArticleTitle.js @@ -0,0 +1,17 @@ +const mergeAtLevel = require('./mergeAtLevel'); + +/** + Edit title of an article + + @param {Summary} summary + @param {String} level + @param {String} newTitle + @return {Summary} +*/ +function editArticleTitle(summary, level, newTitle) { + return mergeAtLevel(summary, level, { + title: newTitle + }); +} + +module.exports = editArticleTitle; diff --git a/packages/gitbook/src/modifiers/summary/editPartTitle.js b/packages/gitbook/src/modifiers/summary/editPartTitle.js new file mode 100644 index 0000000..ace7058 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/editPartTitle.js @@ -0,0 +1,23 @@ +/** + Edit title of a part in the summary + + @param {Summary} summary + @param {Number} index + @param {String} newTitle + @return {Summary} +*/ +function editPartTitle(summary, index, newTitle) { + let parts = summary.getParts(); + + let part = parts.get(index); + if (!part) { + return summary; + } + + part = part.set('title', newTitle); + parts = parts.set(index, part); + + return summary.set('parts', parts); +} + +module.exports = editPartTitle; diff --git a/packages/gitbook/src/modifiers/summary/index.js b/packages/gitbook/src/modifiers/summary/index.js new file mode 100644 index 0000000..f91fdb6 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/index.js @@ -0,0 +1,13 @@ +module.exports = { + insertArticle: require('./insertArticle'), + moveArticle: require('./moveArticle'), + moveArticleAfter: require('./moveArticleAfter'), + removeArticle: require('./removeArticle'), + unshiftArticle: require('./unshiftArticle'), + editArticleTitle: require('./editArticleTitle'), + editArticleRef: require('./editArticleRef'), + + insertPart: require('./insertPart'), + removePart: require('./removePart'), + editPartTitle: require('./editPartTitle') +}; diff --git a/packages/gitbook/src/modifiers/summary/indexArticleLevels.js b/packages/gitbook/src/modifiers/summary/indexArticleLevels.js new file mode 100644 index 0000000..03c26c7 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/indexArticleLevels.js @@ -0,0 +1,23 @@ + +/** + Index levels in an article tree + + @param {Article} + @param {String} baseLevel + @return {Article} +*/ +function indexArticleLevels(article, baseLevel) { + baseLevel = baseLevel || article.getLevel(); + let articles = article.getArticles(); + + articles = articles.map(function(inner, i) { + return indexArticleLevels(inner, baseLevel + '.' + (i + 1)); + }); + + return article.merge({ + level: baseLevel, + articles + }); +} + +module.exports = indexArticleLevels; diff --git a/packages/gitbook/src/modifiers/summary/indexLevels.js b/packages/gitbook/src/modifiers/summary/indexLevels.js new file mode 100644 index 0000000..deb76da --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/indexLevels.js @@ -0,0 +1,17 @@ +const indexPartLevels = require('./indexPartLevels'); + +/** + Index all levels in the summary + + @param {Summary} + @return {Summary} +*/ +function indexLevels(summary) { + let parts = summary.getParts(); + parts = parts.map(indexPartLevels); + + return summary.set('parts', parts); +} + + +module.exports = indexLevels; diff --git a/packages/gitbook/src/modifiers/summary/indexPartLevels.js b/packages/gitbook/src/modifiers/summary/indexPartLevels.js new file mode 100644 index 0000000..6e48778 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/indexPartLevels.js @@ -0,0 +1,24 @@ +const indexArticleLevels = require('./indexArticleLevels'); + +/** + Index levels in a part + + @param {Part} + @param {Number} index + @return {Part} +*/ +function indexPartLevels(part, index) { + const baseLevel = String(index + 1); + let articles = part.getArticles(); + + articles = articles.map(function(inner, i) { + return indexArticleLevels(inner, baseLevel + '.' + (i + 1)); + }); + + return part.merge({ + level: baseLevel, + articles + }); +} + +module.exports = indexPartLevels; diff --git a/packages/gitbook/src/modifiers/summary/insertArticle.js b/packages/gitbook/src/modifiers/summary/insertArticle.js new file mode 100644 index 0000000..537f548 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/insertArticle.js @@ -0,0 +1,49 @@ +const is = require('is'); +const SummaryArticle = require('../../models/summaryArticle'); +const mergeAtLevel = require('./mergeAtLevel'); +const indexArticleLevels = require('./indexArticleLevels'); + +/** + Returns a new Summary with the article at the given level, with + subsequent article shifted. + + @param {Summary} summary + @param {Article} article + @param {String|Article} level: level to insert at + @return {Summary} +*/ +function insertArticle(summary, article, level) { + article = SummaryArticle(article); + level = is.string(level) ? level : level.getLevel(); + + let parent = summary.getParent(level); + if (!parent) { + return summary; + } + + // Find the index to insert at + let articles = parent.getArticles(); + const index = getLeafIndex(level); + + // Insert the article at the right index + articles = articles.insert(index, article); + + // Reindex the level from here + parent = parent.set('articles', articles); + parent = indexArticleLevels(parent); + + return mergeAtLevel(summary, parent.getLevel(), parent); +} + +/** + @param {String} + @return {Number} The index of this level within its parent's children + */ +function getLeafIndex(level) { + const arr = level.split('.').map(function(char) { + return parseInt(char, 10); + }); + return arr[arr.length - 1] - 1; +} + +module.exports = insertArticle; diff --git a/packages/gitbook/src/modifiers/summary/insertPart.js b/packages/gitbook/src/modifiers/summary/insertPart.js new file mode 100644 index 0000000..ea99f89 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/insertPart.js @@ -0,0 +1,19 @@ +const SummaryPart = require('../../models/summaryPart'); +const indexLevels = require('./indexLevels'); + +/** + Returns a new Summary with a part inserted at given index + + @param {Summary} summary + @param {Part} part + @param {Number} index + @return {Summary} +*/ +function insertPart(summary, part, index) { + part = SummaryPart(part); + + const parts = summary.getParts().insert(index, part); + return indexLevels(summary.set('parts', parts)); +} + +module.exports = insertPart; diff --git a/packages/gitbook/src/modifiers/summary/mergeAtLevel.js b/packages/gitbook/src/modifiers/summary/mergeAtLevel.js new file mode 100644 index 0000000..ea01763 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/mergeAtLevel.js @@ -0,0 +1,75 @@ + +/** + Edit a list of articles + + @param {List<Article>} articles + @param {String} level + @param {Article} newArticle + @return {List<Article>} +*/ +function editArticleInList(articles, level, newArticle) { + return articles.map(function(article) { + const articleLevel = article.getLevel(); + + if (articleLevel === level) { + // it is the article to edit + return article.merge(newArticle); + } else if (level.indexOf(articleLevel) === 0) { + // it is a parent + const articles = editArticleInList(article.getArticles(), level, newArticle); + return article.set('articles', articles); + } else { + // This is not the article you are looking for + return article; + } + }); +} + + +/** + Edit an article in a part + + @param {Part} part + @param {String} level + @param {Article} newArticle + @return {Part} +*/ +function editArticleInPart(part, level, newArticle) { + let articles = part.getArticles(); + articles = editArticleInList(articles, level, newArticle); + + return part.set('articles', articles); +} + + +/** + Edit an article, or a part, in a summary. Does a shallow merge. + + @param {Summary} summary + @param {String} level + @param {Article|Part} newValue + @return {Summary} +*/ +function mergeAtLevel(summary, level, newValue) { + const levelParts = level.split('.'); + const partIndex = Number(levelParts[0]) - 1; + + let parts = summary.getParts(); + let part = parts.get(partIndex); + if (!part) { + return summary; + } + + const isEditingPart = levelParts.length < 2; + if (isEditingPart) { + part = part.merge(newValue); + } else { + part = editArticleInPart(part, level, newValue); + } + + parts = parts.set(partIndex, part); + return summary.set('parts', parts); +} + + +module.exports = mergeAtLevel; diff --git a/packages/gitbook/src/modifiers/summary/moveArticle.js b/packages/gitbook/src/modifiers/summary/moveArticle.js new file mode 100644 index 0000000..29d4748 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/moveArticle.js @@ -0,0 +1,25 @@ +const is = require('is'); +const removeArticle = require('./removeArticle'); +const insertArticle = require('./insertArticle'); + +/** + Returns a new summary, with the given article removed from its + origin level, and placed at the given target level. + + @param {Summary} summary + @param {String|SummaryArticle} origin: level to remove + @param {String|SummaryArticle} target: the level where the article will be found + @return {Summary} +*/ +function moveArticle(summary, origin, target) { + // Coerce to level + const originLevel = is.string(origin) ? origin : origin.getLevel(); + const targetLevel = is.string(target) ? target : target.getLevel(); + const article = summary.getByLevel(originLevel); + + // Remove first + const removed = removeArticle(summary, originLevel); + return insertArticle(removed, article, targetLevel); +} + +module.exports = moveArticle; diff --git a/packages/gitbook/src/modifiers/summary/moveArticleAfter.js b/packages/gitbook/src/modifiers/summary/moveArticleAfter.js new file mode 100644 index 0000000..a1ed28f --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/moveArticleAfter.js @@ -0,0 +1,60 @@ +const is = require('is'); +const removeArticle = require('./removeArticle'); +const insertArticle = require('./insertArticle'); + +/** + Returns a new summary, with the an article moved after another + article. Unlike `moveArticle`, does not ensure that the article + will be found at the target's level plus one. + + @param {Summary} summary + @param {String|SummaryArticle} origin + @param {String|SummaryArticle} afterTarget + @return {Summary} +*/ +function moveArticleAfter(summary, origin, afterTarget) { + // Coerce to level + const originLevel = is.string(origin) ? origin : origin.getLevel(); + const afterTargetLevel = is.string(afterTarget) ? afterTarget : afterTarget.getLevel(); + const article = summary.getByLevel(originLevel); + + const targetLevel = increment(afterTargetLevel); + + if (targetLevel < origin) { + // Remove first + const removed = removeArticle(summary, originLevel); + // Insert then + return insertArticle(removed, article, targetLevel); + } else { + // Insert right after first + const inserted = insertArticle(summary, article, targetLevel); + // Remove old one + return removeArticle(inserted, originLevel); + } +} + +/** + @param {String} + @return {Array<Number>} + */ +function levelToArray(l) { + return l.split('.').map(function(char) { + return parseInt(char, 10); + }); +} + +/** + @param {Array<Number>} + @return {String} + */ +function arrayToLevel(a) { + return a.join('.'); +} + +function increment(level) { + level = levelToArray(level); + level[level.length - 1]++; + return arrayToLevel(level); +} + +module.exports = moveArticleAfter; diff --git a/packages/gitbook/src/modifiers/summary/removeArticle.js b/packages/gitbook/src/modifiers/summary/removeArticle.js new file mode 100644 index 0000000..0c4cd33 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/removeArticle.js @@ -0,0 +1,37 @@ +const is = require('is'); +const mergeAtLevel = require('./mergeAtLevel'); +const indexArticleLevels = require('./indexArticleLevels'); + +/** + Remove an article from a level. + + @param {Summary} summary + @param {String|SummaryArticle} level: level to remove + @return {Summary} +*/ +function removeArticle(summary, level) { + // Coerce to level + level = is.string(level) ? level : level.getLevel(); + + let parent = summary.getParent(level); + + let articles = parent.getArticles(); + // Find the index to remove + const index = articles.findIndex(function(art) { + return art.getLevel() === level; + }); + if (index === -1) { + return summary; + } + + // Remove from children + articles = articles.remove(index); + parent = parent.set('articles', articles); + + // Reindex the level from here + parent = indexArticleLevels(parent); + + return mergeAtLevel(summary, parent.getLevel(), parent); +} + +module.exports = removeArticle; diff --git a/packages/gitbook/src/modifiers/summary/removePart.js b/packages/gitbook/src/modifiers/summary/removePart.js new file mode 100644 index 0000000..30502dc --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/removePart.js @@ -0,0 +1,15 @@ +const indexLevels = require('./indexLevels'); + +/** + Remove a part at given index + + @param {Summary} summary + @param {Number|} index + @return {Summary} +*/ +function removePart(summary, index) { + const parts = summary.getParts().remove(index); + return indexLevels(summary.set('parts', parts)); +} + +module.exports = removePart; diff --git a/packages/gitbook/src/modifiers/summary/unshiftArticle.js b/packages/gitbook/src/modifiers/summary/unshiftArticle.js new file mode 100644 index 0000000..c5810f0 --- /dev/null +++ b/packages/gitbook/src/modifiers/summary/unshiftArticle.js @@ -0,0 +1,29 @@ +const SummaryArticle = require('../../models/summaryArticle'); +const SummaryPart = require('../../models/summaryPart'); + +const indexLevels = require('./indexLevels'); + +/** + Insert an article at the beginning of summary + + @param {Summary} summary + @param {Article} article + @return {Summary} +*/ +function unshiftArticle(summary, article) { + article = SummaryArticle(article); + + let parts = summary.getParts(); + let part = parts.get(0) || SummaryPart(); + + let articles = part.getArticles(); + articles = articles.unshift(article); + part = part.set('articles', articles); + + parts = parts.set(0, part); + summary = summary.set('parts', parts); + + return indexLevels(summary); +} + +module.exports = unshiftArticle; diff --git a/packages/gitbook/src/output/__tests__/createMock.js b/packages/gitbook/src/output/__tests__/createMock.js new file mode 100644 index 0000000..09b93da --- /dev/null +++ b/packages/gitbook/src/output/__tests__/createMock.js @@ -0,0 +1,38 @@ +const Immutable = require('immutable'); + +const Output = require('../../models/output'); +const Book = require('../../models/book'); +const parseBook = require('../../parse/parseBook'); +const createMockFS = require('../../fs/mock'); +const preparePlugins = require('../preparePlugins'); + +/** + * Create an output using a generator + * + * FOR TESTING PURPOSE ONLY + * + * @param {Generator} generator + * @param {Map<String:String|Map>} files + * @return {Promise<Output>} + */ +function createMockOutput(generator, files, options) { + const fs = createMockFS(files); + let book = Book.createForFS(fs); + const state = generator.State ? generator.State({}) : Immutable.Map(); + + book = book.setLogLevel('disabled'); + options = generator.Options(options); + + return parseBook(book) + .then(function(resultBook) { + return new Output({ + book: resultBook, + options, + state, + generator: generator.name + }); + }) + .then(preparePlugins); +} + +module.exports = createMockOutput; diff --git a/packages/gitbook/src/output/__tests__/ebook.js b/packages/gitbook/src/output/__tests__/ebook.js new file mode 100644 index 0000000..8b7096c --- /dev/null +++ b/packages/gitbook/src/output/__tests__/ebook.js @@ -0,0 +1,15 @@ +const generateMock = require('./generateMock'); +const EbookGenerator = require('../ebook'); + +describe('EbookGenerator', function() { + + it('should generate a SUMMARY.html', function() { + return generateMock(EbookGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('SUMMARY.html'); + expect(folder).toHaveFile('index.html'); + }); + }); +}); diff --git a/packages/gitbook/src/output/__tests__/generateMock.js b/packages/gitbook/src/output/__tests__/generateMock.js new file mode 100644 index 0000000..6ae1de2 --- /dev/null +++ b/packages/gitbook/src/output/__tests__/generateMock.js @@ -0,0 +1,40 @@ +const tmp = require('tmp'); + +const Book = require('../../models/book'); +const createMockFS = require('../../fs/mock'); +const parseBook = require('../../parse/parseBook'); +const generateBook = require('../generateBook'); + +/** + * Generate a book using a generator + * And returns the path to the output dir. + * + * FOR TESTING PURPOSE ONLY + * + * @param {Generator} + * @param {Map<String:String|Map>} files + * @return {Promise<String>} + */ +function generateMock(Generator, files) { + const fs = createMockFS(files); + let book = Book.createForFS(fs); + let dir; + + try { + dir = tmp.dirSync(); + } catch (err) { + throw err; + } + + book = book.setLogLevel('disabled'); + + return parseBook(book) + .then((resultBook) => { + return generateBook(Generator, resultBook, { + root: dir.name + }); + }) + .thenResolve(dir.name); +} + +module.exports = generateMock; diff --git a/packages/gitbook/src/output/__tests__/json.js b/packages/gitbook/src/output/__tests__/json.js new file mode 100644 index 0000000..d4992ec --- /dev/null +++ b/packages/gitbook/src/output/__tests__/json.js @@ -0,0 +1,46 @@ +const generateMock = require('./generateMock'); +const JSONGenerator = require('../json'); + +describe('JSONGenerator', function() { + + it('should generate a README.json', function() { + return generateMock(JSONGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('README.json'); + }); + }); + + it('should generate a json file for each articles', function() { + return generateMock(JSONGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('README.json'); + expect(folder).toHaveFile('test/page.json'); + }); + }); + + it('should generate a multilingual book', function() { + return generateMock(JSONGenerator, { + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'en': { + 'README.md': 'Hello' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('en/README.json'); + expect(folder).toHaveFile('fr/README.json'); + expect(folder).toHaveFile('README.json'); + }); + }); +}); + diff --git a/packages/gitbook/src/output/__tests__/website.js b/packages/gitbook/src/output/__tests__/website.js new file mode 100644 index 0000000..4c10f1e --- /dev/null +++ b/packages/gitbook/src/output/__tests__/website.js @@ -0,0 +1,140 @@ +const generateMock = require('./generateMock'); +const WebsiteGenerator = require('../website'); + +describe('WebsiteGenerator', () => { + + it('should generate an index.html', () => { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World' + }) + .then((folder) => { + expect(folder).toHaveFile('index.html'); + }); + }); + + it('should generate an index.html for custom README', () => { + return generateMock(WebsiteGenerator, { + 'CustomReadme.md': 'Hello World', + 'book.json': '{ "structure": { "readme": "CustomReadme.md" } }' + }) + .then((folder) => { + expect(folder).toHaveFile('index.html'); + expect(folder).toNotHaveFile('CustomReadme.html'); + }); + }); + + describe('Glossary', () => { + let folder; + + before(() => { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '* [Deep](folder/page.md)', + 'folder': { + 'page.md': 'Hello World' + }, + 'GLOSSARY.md': '# Glossary\n\n## Hello\n\nHello World' + }) + .then((_folder) => { + folder = _folder; + }); + }); + + it('should generate a GLOSSARY.html', () => { + expect(folder).toHaveFile('GLOSSARY.html'); + }); + + it('should accept a custom glossary file', () => { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'book.json': '{ "structure": { "glossary": "custom.md" } }', + 'custom.md': '# Glossary\n\n## Hello\n\nHello World' + }) + .then((result) => { + expect(result).toHaveFile('custom.html'); + expect(result).toNotHaveFile('GLOSSARY.html'); + }); + }); + }); + + + it('should copy asset files', () => { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'myJsFile.js': 'var a = "test";', + 'folder': { + 'AnotherAssetFile.md': '# Even md' + } + }) + .then((folder) => { + expect(folder).toHaveFile('index.html'); + expect(folder).toHaveFile('myJsFile.js'); + expect(folder).toHaveFile('folder/AnotherAssetFile.md'); + }); + }); + + it('should generate an index.html for AsciiDoc', () => { + return generateMock(WebsiteGenerator, { + 'README.adoc': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + }); + }); + + it('should generate an HTML file for each articles', () => { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + expect(folder).toHaveFile('test/page.html'); + }); + }); + + it('should not generate file if entry file doesn\'t exist', () => { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page 1](page.md)\n* [Page 2](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then((folder) => { + expect(folder).toHaveFile('index.html'); + expect(folder).toNotHaveFile('page.html'); + expect(folder).toHaveFile('test/page.html'); + }); + }); + + it('should generate a multilingual book', () => { + return generateMock(WebsiteGenerator, { + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'en': { + 'README.md': 'Hello' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }) + .then((folder) => { + // It should generate languages + expect(folder).toHaveFile('en/index.html'); + expect(folder).toHaveFile('fr/index.html'); + + // Should not copy languages as assets + expect(folder).toNotHaveFile('en/README.md'); + expect(folder).toNotHaveFile('fr/README.md'); + + // Should copy assets only once + expect(folder).toHaveFile('gitbook/core.js'); + expect(folder).toNotHaveFile('en/gitbook/core.js'); + + expect(folder).toHaveFile('index.html'); + }); + }); +}); diff --git a/packages/gitbook/src/output/callHook.js b/packages/gitbook/src/output/callHook.js new file mode 100644 index 0000000..34c16ab --- /dev/null +++ b/packages/gitbook/src/output/callHook.js @@ -0,0 +1,60 @@ +const Promise = require('../utils/promise'); +const timing = require('../utils/timing'); +const Api = require('../api'); + +function defaultGetArgument() { + return undefined; +} + +function defaultHandleResult(output, result) { + return output; +} + +/** + * Call a "global" hook for an output. Hooks are functions exported by plugins. + * + * @param {String} name + * @param {Function(Output) -> Mixed} getArgument + * @param {Function(Output, result) -> Output} handleResult + * @param {Output} output + * @return {Promise<Output>} + */ +function callHook(name, getArgument, handleResult, output) { + getArgument = getArgument || defaultGetArgument; + handleResult = handleResult || defaultHandleResult; + + const logger = output.getLogger(); + const plugins = output.getPlugins(); + + logger.debug.ln('calling hook "' + name + '"'); + + // Create the JS context for plugins + const context = Api.encodeGlobal(output); + + return timing.measure( + 'call.hook.' + name, + + // Get the arguments + Promise(getArgument(output)) + + // Call the hooks in serie + .then(function(arg) { + return Promise.reduce(plugins, function(prev, plugin) { + const hook = plugin.getHook(name); + if (!hook) { + return prev; + } + + return hook.call(context, prev); + }, arg); + }) + + // Handle final result + .then(function(result) { + output = Api.decodeGlobal(output, context); + return handleResult(output, result); + }) + ); +} + +module.exports = callHook; diff --git a/packages/gitbook/src/output/callPageHook.js b/packages/gitbook/src/output/callPageHook.js new file mode 100644 index 0000000..0c7adfa --- /dev/null +++ b/packages/gitbook/src/output/callPageHook.js @@ -0,0 +1,28 @@ +const Api = require('../api'); +const callHook = require('./callHook'); + +/** + * Call a hook for a specific page. + * + * @param {String} name + * @param {Output} output + * @param {Page} page + * @return {Promise<Page>} + */ +function callPageHook(name, output, page) { + return callHook( + name, + + function(out) { + return Api.encodePage(out, page); + }, + + function(out, result) { + return Api.decodePage(out, page, result); + }, + + output + ); +} + +module.exports = callPageHook; diff --git a/packages/gitbook/src/output/createTemplateEngine.js b/packages/gitbook/src/output/createTemplateEngine.js new file mode 100644 index 0000000..f405f36 --- /dev/null +++ b/packages/gitbook/src/output/createTemplateEngine.js @@ -0,0 +1,48 @@ +const Templating = require('../templating'); +const TemplateEngine = require('../models/templateEngine'); + +const Api = require('../api'); +const Plugins = require('../plugins'); + +const defaultBlocks = require('../constants/defaultBlocks'); +const defaultFilters = require('../constants/defaultFilters'); + +/** + * Create template engine for an output. + * It adds default filters/blocks, then add the ones from plugins. + * + * This template engine is used to compile pages. + * + * @param {Output} output + * @return {TemplateEngine} + */ +function createTemplateEngine(output) { + const { git } = output; + const plugins = output.getPlugins(); + const book = output.getBook(); + const rootFolder = book.getContentRoot(); + const logger = book.getLogger(); + + let filters = Plugins.listFilters(plugins); + let blocks = Plugins.listBlocks(plugins); + + // Extend with default + blocks = defaultBlocks.merge(blocks); + filters = defaultFilters.merge(filters); + + // Create loader + const transformFn = Templating.replaceShortcuts.bind(null, blocks); + const loader = new Templating.ConrefsLoader(rootFolder, transformFn, logger, git); + + // Create API context + const context = Api.encodeGlobal(output); + + return new TemplateEngine({ + filters, + blocks, + loader, + context + }); +} + +module.exports = createTemplateEngine; diff --git a/packages/gitbook/src/output/ebook/getConvertOptions.js b/packages/gitbook/src/output/ebook/getConvertOptions.js new file mode 100644 index 0000000..b37c68e --- /dev/null +++ b/packages/gitbook/src/output/ebook/getConvertOptions.js @@ -0,0 +1,73 @@ +const extend = require('extend'); + +const Promise = require('../../utils/promise'); +const getPDFTemplate = require('./getPDFTemplate'); +const getCoverPath = require('./getCoverPath'); + +/** + Generate options for ebook-convert + + @param {Output} + @return {Promise<Object>} +*/ +function getConvertOptions(output) { + const options = output.getOptions(); + const format = options.get('format'); + + const book = output.getBook(); + const config = book.getConfig(); + + return Promise() + .then(function() { + const coverPath = getCoverPath(output); + let options = { + '--cover': coverPath, + '--title': config.getValue('title'), + '--comments': config.getValue('description'), + '--isbn': config.getValue('isbn'), + '--authors': config.getValue('author'), + '--language': book.getLanguage() || config.getValue('language'), + '--book-producer': 'GitBook', + '--publisher': 'GitBook', + '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]', + '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]', + '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]', + '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]', + '--max-levels': '1', + '--no-chapters-in-toc': true, + '--breadth-first': true, + '--dont-split-on-page-breaks': format === 'epub' ? true : undefined + }; + + if (format !== 'pdf') { + return options; + } + + return Promise.all([ + getPDFTemplate(output, 'header'), + getPDFTemplate(output, 'footer') + ]) + .spread(function(headerTpl, footerTpl) { + const pdfOptions = config.getValue('pdf').toJS(); + + return options = extend(options, { + '--chapter-mark': String(pdfOptions.chapterMark), + '--page-breaks-before': String(pdfOptions.pageBreaksBefore), + '--margin-left': String(pdfOptions.margin.left), + '--margin-right': String(pdfOptions.margin.right), + '--margin-top': String(pdfOptions.margin.top), + '--margin-bottom': String(pdfOptions.margin.bottom), + '--pdf-default-font-size': String(pdfOptions.fontSize), + '--pdf-mono-font-size': String(pdfOptions.fontSize), + '--paper-size': String(pdfOptions.paperSize), + '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers), + '--pdf-sans-family': String(pdfOptions.fontFamily), + '--pdf-header-template': headerTpl, + '--pdf-footer-template': footerTpl + }); + }); + }); +} + + +module.exports = getConvertOptions; diff --git a/packages/gitbook/src/output/ebook/getCoverPath.js b/packages/gitbook/src/output/ebook/getCoverPath.js new file mode 100644 index 0000000..cf18c8d --- /dev/null +++ b/packages/gitbook/src/output/ebook/getCoverPath.js @@ -0,0 +1,30 @@ +const path = require('path'); +const fs = require('../../utils/fs'); + +/** + Resolve path to cover file to use + + @param {Output} + @return {String} +*/ +function getCoverPath(output) { + const outputRoot = output.getRoot(); + const book = output.getBook(); + const config = book.getConfig(); + const coverName = config.getValue('cover', 'cover.jpg'); + + // Resolve to absolute + let cover = fs.pickFile(outputRoot, coverName); + if (cover) { + return cover; + } + + // Multilingual? try parent folder + if (book.isLanguageBook()) { + cover = fs.pickFile(path.join(outputRoot, '..'), coverName); + } + + return cover; +} + +module.exports = getCoverPath; diff --git a/packages/gitbook/src/output/ebook/getPDFTemplate.js b/packages/gitbook/src/output/ebook/getPDFTemplate.js new file mode 100644 index 0000000..53c7a82 --- /dev/null +++ b/packages/gitbook/src/output/ebook/getPDFTemplate.js @@ -0,0 +1,36 @@ +const juice = require('juice'); + +const JSONUtils = require('../../json'); +const render = require('../../browser/render'); +const Promise = require('../../utils/promise'); + +/** + * Generate PDF header/footer templates + * + * @param {Output} output + * @param {String} type ("footer" or "header") + * @return {String} html + */ +function getPDFTemplate(output, type) { + const outputRoot = output.getRoot(); + const plugins = output.getPlugins(); + + // Generate initial state + const initialState = JSONUtils.encodeState(output); + initialState.page = { + num: '_PAGENUM_', + title: '_SECTION_' + }; + + // Render the theme + const html = render(plugins, initialState, 'ebook', `pdf:${type}`); + + // Inline CSS + return Promise.nfcall(juice.juiceResources, html, { + webResources: { + relativeTo: outputRoot + } + }); +} + +module.exports = getPDFTemplate; diff --git a/packages/gitbook/src/output/ebook/index.js b/packages/gitbook/src/output/ebook/index.js new file mode 100644 index 0000000..c5c07c2 --- /dev/null +++ b/packages/gitbook/src/output/ebook/index.js @@ -0,0 +1,9 @@ +const extend = require('extend'); +const WebsiteGenerator = require('../website'); + +module.exports = extend({}, WebsiteGenerator, { + name: 'ebook', + Options: require('./options'), + onPage: require('./onPage'), + onFinish: require('./onFinish') +}); diff --git a/packages/gitbook/src/output/ebook/onFinish.js b/packages/gitbook/src/output/ebook/onFinish.js new file mode 100644 index 0000000..7db757f --- /dev/null +++ b/packages/gitbook/src/output/ebook/onFinish.js @@ -0,0 +1,85 @@ +const path = require('path'); + +const JSONUtils = require('../../json'); +const Promise = require('../../utils/promise'); +const error = require('../../utils/error'); +const command = require('../../utils/command'); +const writeFile = require('../helper/writeFile'); +const render = require('../../browser/render'); + +const getConvertOptions = require('./getConvertOptions'); +const SUMMARY_FILE = 'SUMMARY.html'; + +/** + * Write the SUMMARY.html + * + * @param {Output} output + * @return {Output} output + */ +function writeSummary(output) { + const plugins = output.getPlugins(); + + // Generate initial state + const initialState = JSONUtils.encodeState(output); + + // Render using React + const html = render(plugins, initialState, 'ebook', 'ebook:summary'); + + return writeFile(output, SUMMARY_FILE, html); +} + +/** + * Generate the ebook file as "index.pdf" + * + * @param {Output} output + * @return {Output} output + */ +function runEbookConvert(output) { + const logger = output.getLogger(); + const options = output.getOptions(); + const format = options.get('format'); + const outputFolder = output.getRoot(); + + if (!format) { + return Promise(output); + } + + return getConvertOptions(output) + .then(function(options) { + const cmd = [ + 'ebook-convert', + path.resolve(outputFolder, SUMMARY_FILE), + path.resolve(outputFolder, 'index.' + format), + command.optionsToShellArgs(options) + ].join(' '); + + return command.exec(cmd) + .progress(function(data) { + logger.debug(data); + }) + .fail(function(err) { + if (err.code == 127) { + throw error.RequireInstallError({ + cmd: 'ebook-convert', + install: 'Install it from Calibre: https://calibre-ebook.com' + }); + } + + throw error.EbookError(err); + }); + }) + .thenResolve(output); +} + +/** + * Finish the generation, generates the SUMMARY.html + * + * @param {Output} output + * @return {Output} output + */ +function onFinish(output) { + return writeSummary(output) + .then(runEbookConvert); +} + +module.exports = onFinish; diff --git a/packages/gitbook/src/output/ebook/onPage.js b/packages/gitbook/src/output/ebook/onPage.js new file mode 100644 index 0000000..a7c2137 --- /dev/null +++ b/packages/gitbook/src/output/ebook/onPage.js @@ -0,0 +1,25 @@ +const WebsiteGenerator = require('../website'); +const Modifiers = require('../modifiers'); + +/** + * Write a page for ebook output. It renders it just as the website generator + * except that it inline assets. + * + * @param {Output} output + * @param {Output} output + */ +function onPage(output, page) { + const options = output.getOptions(); + + // Inline assets + return Modifiers.modifyHTML(page, [ + Modifiers.inlineAssets(options.get('root'), page.getFile().getPath()) + ]) + + // Write page using website generator + .then(function(resultPage) { + return WebsiteGenerator.onPage(output, resultPage); + }); +} + +module.exports = onPage; diff --git a/packages/gitbook/src/output/ebook/options.js b/packages/gitbook/src/output/ebook/options.js new file mode 100644 index 0000000..d192fd2 --- /dev/null +++ b/packages/gitbook/src/output/ebook/options.js @@ -0,0 +1,14 @@ +const Immutable = require('immutable'); + +const Options = Immutable.Record({ + // Root folder for the output + root: String(), + // Prefix for generation + prefix: String('ebook'), + // Format to generate using ebook-convert + format: String(), + // Force use of absolute urls ("index.html" instead of "/") + directoryIndex: Boolean(false) +}); + +module.exports = Options; diff --git a/packages/gitbook/src/output/generateAssets.js b/packages/gitbook/src/output/generateAssets.js new file mode 100644 index 0000000..f926492 --- /dev/null +++ b/packages/gitbook/src/output/generateAssets.js @@ -0,0 +1,26 @@ +const Promise = require('../utils/promise'); + +/** + * Output all assets using a generator + * + * @param {Generator} generator + * @param {Output} output + * @return {Promise<Output>} + */ +function generateAssets(generator, output) { + const assets = output.getAssets(); + const logger = output.getLogger(); + + // Is generator ignoring assets? + if (!generator.onAsset) { + return Promise(output); + } + + return Promise.reduce(assets, function(out, assetFile) { + logger.debug.ln('copy asset "' + assetFile + '"'); + + return generator.onAsset(out, assetFile); + }, output); +} + +module.exports = generateAssets; diff --git a/packages/gitbook/src/output/generateBook.js b/packages/gitbook/src/output/generateBook.js new file mode 100644 index 0000000..0e2c230 --- /dev/null +++ b/packages/gitbook/src/output/generateBook.js @@ -0,0 +1,193 @@ +const path = require('path'); +const Immutable = require('immutable'); + +const Output = require('../models/output'); +const Promise = require('../utils/promise'); +const fs = require('../utils/fs'); + +const callHook = require('./callHook'); +const preparePlugins = require('./preparePlugins'); +const preparePages = require('./preparePages'); +const prepareAssets = require('./prepareAssets'); +const generateAssets = require('./generateAssets'); +const generatePages = require('./generatePages'); + +/** + * Process an output to generate the book + * + * @param {Generator} generator + * @param {Output} output + * @return {Promise<Output>} + */ +function processOutput(generator, startOutput) { + return Promise(startOutput) + .then(preparePlugins) + .then(preparePages) + .then(prepareAssets) + + .then( + callHook.bind(null, + 'config', + function(output) { + const book = output.getBook(); + const config = book.getConfig(); + const values = config.getValues(); + + return values.toJS(); + }, + function(output, result) { + let book = output.getBook(); + let config = book.getConfig(); + + config = config.updateValues(result); + book = book.set('config', config); + return output.set('book', book); + } + ) + ) + + .then( + callHook.bind(null, + 'init', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ) + + .then((output) => { + if (!generator.onInit) { + return output; + } + + return generator.onInit(output); + }) + + .then(generateAssets.bind(null, generator)) + .then(generatePages.bind(null, generator)) + + .tap((output) => { + const book = output.getBook(); + + if (!book.isMultilingual()) { + return; + } + + const logger = book.getLogger(); + const books = book.getBooks(); + const outputRoot = output.getRoot(); + const plugins = output.getPlugins(); + const state = output.getState(); + const options = output.getOptions(); + + return Promise.forEach(books, function(langBook) { + // Inherits plugins list, options and state + const langOptions = options.set('root', path.join(outputRoot, langBook.getLanguage())); + const langOutput = new Output({ + book: langBook, + options: langOptions, + state, + generator: generator.name, + plugins + }); + + logger.info.ln(''); + logger.info.ln('generating language "' + langBook.getLanguage() + '"'); + return processOutput(generator, langOutput); + }); + }) + + .then(callHook.bind(null, + 'finish:before', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ) + + .then((output) => { + if (!generator.onFinish) { + return output; + } + + return generator.onFinish(output); + }) + + .then(callHook.bind(null, + 'finish', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ); +} + +/** + * Generate a book using a generator. + * + * The overall process is: + * 1. List and load plugins for this book + * 2. Call hook "config" + * 3. Call hook "init" + * 4. Initialize generator + * 5. List all assets and pages + * 6. Copy all assets to output + * 7. Generate all pages + * 8. Call hook "finish:before" + * 9. Finish generation + * 10. Call hook "finish" + * + * + * @param {Generator} generator + * @param {Book} book + * @param {Object} options + * @return {Promise<Output>} + */ +function generateBook(generator, book, options) { + options = generator.Options(options); + const state = generator.State ? generator.State({}) : Immutable.Map(); + const start = Date.now(); + + return Promise( + new Output({ + book, + options, + state, + generator: generator.name + }) + ) + + // Cleanup output folder + .then((output) => { + const logger = output.getLogger(); + const rootFolder = output.getRoot(); + + logger.debug.ln('cleanup folder "' + rootFolder + '"'); + return fs.ensureFolder(rootFolder) + .thenResolve(output); + }) + + .then(output => processOutput(generator, output)) + + // Log duration and end message + .then((output) => { + const logger = output.getLogger(); + const end = Date.now(); + const duration = (end - start) / 1000; + + logger.info.ok('generation finished with success in ' + duration.toFixed(1) + 's !'); + + return output; + }); +} + +module.exports = generateBook; diff --git a/packages/gitbook/src/output/generatePage.js b/packages/gitbook/src/output/generatePage.js new file mode 100644 index 0000000..7375f1d --- /dev/null +++ b/packages/gitbook/src/output/generatePage.js @@ -0,0 +1,68 @@ +const path = require('path'); + +const Promise = require('../utils/promise'); +const error = require('../utils/error'); +const timing = require('../utils/timing'); + +const Templating = require('../templating'); +const JSONUtils = require('../json'); +const createTemplateEngine = require('./createTemplateEngine'); +const callPageHook = require('./callPageHook'); + +/** + * Prepare and generate HTML for a page + * + * @param {Output} output + * @param {Page} page + * @return {Promise<Page>} + */ +function generatePage(output, page) { + const book = output.getBook(); + const engine = createTemplateEngine(output); + + return timing.measure( + 'page.generate', + Promise(page) + .then(function(resultPage) { + const file = resultPage.getFile(); + const filePath = file.getPath(); + const parser = file.getParser(); + const context = JSONUtils.encodeState(output, resultPage); + + if (!parser) { + return Promise.reject(error.FileNotParsableError({ + filename: filePath + })); + } + + // Call hook "page:before" + return callPageHook('page:before', output, resultPage) + + // Escape code blocks with raw tags + .then((currentPage) => { + return parser.preparePage(currentPage.getContent()); + }) + + // Render templating syntax + .then((content) => { + const absoluteFilePath = path.join(book.getContentRoot(), filePath); + return Templating.render(engine, absoluteFilePath, content, context); + }) + + // Parse with markdown/asciidoc parser + .then((content) => parser.parsePage(content)) + + // Return new page + .then(({content}) => { + return resultPage.set('content', content); + }) + + // Call final hook + .then((currentPage) => { + return callPageHook('page', output, currentPage); + }); + }) + ); +} + +module.exports = generatePage; diff --git a/packages/gitbook/src/output/generatePages.js b/packages/gitbook/src/output/generatePages.js new file mode 100644 index 0000000..21b6610 --- /dev/null +++ b/packages/gitbook/src/output/generatePages.js @@ -0,0 +1,36 @@ +const Promise = require('../utils/promise'); +const generatePage = require('./generatePage'); + +/** + Output all pages using a generator + + @param {Generator} generator + @param {Output} output + @return {Promise<Output>} +*/ +function generatePages(generator, output) { + const pages = output.getPages(); + const logger = output.getLogger(); + + // Is generator ignoring assets? + if (!generator.onPage) { + return Promise(output); + } + + return Promise.reduce(pages, function(out, page) { + const file = page.getFile(); + + logger.debug.ln('generate page "' + file.getPath() + '"'); + + return generatePage(out, page) + .then(function(resultPage) { + return generator.onPage(out, resultPage); + }) + .fail(function(err) { + logger.error.ln('error while generating page "' + file.getPath() + '":'); + throw err; + }); + }, output); +} + +module.exports = generatePages; diff --git a/packages/gitbook/src/output/getModifiers.js b/packages/gitbook/src/output/getModifiers.js new file mode 100644 index 0000000..3007b02 --- /dev/null +++ b/packages/gitbook/src/output/getModifiers.js @@ -0,0 +1,42 @@ +const Modifiers = require('./modifiers'); + +/** + * Return default modifier to prepare a page for + * rendering. + * + * @return {Array<Modifier>} + */ +function getModifiers(output, page) { + const book = output.getBook(); + const glossary = book.getGlossary(); + const file = page.getFile(); + + // Map of urls + const urls = output.getURLIndex(); + + // Glossary entries + const entries = glossary.getEntries(); + const glossaryFile = glossary.getFile(); + const glossaryFilename = urls.resolveToURL(glossaryFile.getPath()); + + // Current file path + const currentFilePath = file.getPath(); + + return [ + // Normalize IDs on headings + Modifiers.addHeadingId, + + // Annotate text with glossary entries + Modifiers.annotateText.bind(null, entries, glossaryFilename), + + // Resolve images + Modifiers.resolveImages.bind(null, currentFilePath), + + // Resolve links (.md -> .html) + Modifiers.resolveLinks.bind(null, + (filePath => urls.resolveToURLFrom(currentFilePath, filePath)) + ) + ]; +} + +module.exports = getModifiers; diff --git a/packages/gitbook/src/output/helper/index.js b/packages/gitbook/src/output/helper/index.js new file mode 100644 index 0000000..f8bc109 --- /dev/null +++ b/packages/gitbook/src/output/helper/index.js @@ -0,0 +1,2 @@ + +module.exports = {}; diff --git a/packages/gitbook/src/output/helper/writeFile.js b/packages/gitbook/src/output/helper/writeFile.js new file mode 100644 index 0000000..01a8e68 --- /dev/null +++ b/packages/gitbook/src/output/helper/writeFile.js @@ -0,0 +1,23 @@ +const path = require('path'); +const fs = require('../../utils/fs'); + +/** + Write a file to the output folder + + @param {Output} output + @param {String} filePath + @param {Buffer|String} content + @return {Promise} +*/ +function writeFile(output, filePath, content) { + const rootFolder = output.getRoot(); + filePath = path.join(rootFolder, filePath); + + return fs.ensureFile(filePath) + .then(function() { + return fs.writeFile(filePath, content); + }) + .thenResolve(output); +} + +module.exports = writeFile; diff --git a/packages/gitbook/src/output/index.js b/packages/gitbook/src/output/index.js new file mode 100644 index 0000000..574b3df --- /dev/null +++ b/packages/gitbook/src/output/index.js @@ -0,0 +1,24 @@ +const Immutable = require('immutable'); + +const generators = Immutable.List([ + require('./json'), + require('./website'), + require('./ebook') +]); + +/** + Return a specific generator by its name + + @param {String} + @return {Generator} +*/ +function getGenerator(name) { + return generators.find(function(generator) { + return generator.name == name; + }); +} + +module.exports = { + generate: require('./generateBook'), + getGenerator +}; diff --git a/packages/gitbook/src/output/json/index.js b/packages/gitbook/src/output/json/index.js new file mode 100644 index 0000000..361da06 --- /dev/null +++ b/packages/gitbook/src/output/json/index.js @@ -0,0 +1,7 @@ + +module.exports = { + name: 'json', + Options: require('./options'), + onPage: require('./onPage'), + onFinish: require('./onFinish') +}; diff --git a/packages/gitbook/src/output/json/onFinish.js b/packages/gitbook/src/output/json/onFinish.js new file mode 100644 index 0000000..24f5159 --- /dev/null +++ b/packages/gitbook/src/output/json/onFinish.js @@ -0,0 +1,48 @@ +const path = require('path'); + +const Promise = require('../../utils/promise'); +const fs = require('../../utils/fs'); +const JSONUtils = require('../../json'); + +/** + * Finish the generation + * + * @param {Output} + * @return {Output} + */ +function onFinish(output) { + const book = output.getBook(); + const outputRoot = output.getRoot(); + const urls = output.getURLIndex(); + + if (!book.isMultilingual()) { + return Promise(output); + } + + // Get main language + const languages = book.getLanguages(); + const mainLanguage = languages.getDefaultLanguage(); + + // Read the main JSON + return fs.readFile(path.resolve(outputRoot, mainLanguage.getID(), 'README.json'), 'utf8') + + // Extend the JSON + .then(function(content) { + const json = JSON.parse(content); + + json.languages = JSONUtils.encodeLanguages(languages, urls); + + return json; + }) + + .then(function(json) { + return fs.writeFile( + path.resolve(outputRoot, 'README.json'), + JSON.stringify(json, null, 4) + ); + }) + + .thenResolve(output); +} + +module.exports = onFinish; diff --git a/packages/gitbook/src/output/json/onPage.js b/packages/gitbook/src/output/json/onPage.js new file mode 100644 index 0000000..f31fadc --- /dev/null +++ b/packages/gitbook/src/output/json/onPage.js @@ -0,0 +1,43 @@ +const JSONUtils = require('../../json'); +const PathUtils = require('../../utils/path'); +const Modifiers = require('../modifiers'); +const writeFile = require('../helper/writeFile'); +const getModifiers = require('../getModifiers'); + +const JSON_VERSION = '3'; + +/** + * Write a page as a json file + * + * @param {Output} output + * @param {Page} page + */ +function onPage(output, page) { + const file = page.getFile(); + const readme = output.getBook().getReadme().getFile(); + + return Modifiers.modifyHTML(page, getModifiers(output, page)) + .then(function(resultPage) { + // Generate the JSON + const json = JSONUtils.encodeState(output, resultPage); + + // Delete some private properties + delete json.config; + + // Specify JSON output version + json.version = JSON_VERSION; + + // File path in the output folder + let filePath = file.getPath() == readme.getPath() ? 'README.json' : file.getPath(); + filePath = PathUtils.setExtension(filePath, '.json'); + + // Write it to the disk + return writeFile( + output, + filePath, + JSON.stringify(json, null, 4) + ); + }); +} + +module.exports = onPage; diff --git a/packages/gitbook/src/output/json/options.js b/packages/gitbook/src/output/json/options.js new file mode 100644 index 0000000..2a9de0e --- /dev/null +++ b/packages/gitbook/src/output/json/options.js @@ -0,0 +1,8 @@ +const Immutable = require('immutable'); + +const Options = Immutable.Record({ + // Root folder for the output + root: String() +}); + +module.exports = Options; diff --git a/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js b/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js new file mode 100644 index 0000000..4d77e75 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js @@ -0,0 +1,25 @@ +const cheerio = require('cheerio'); +const addHeadingId = require('../addHeadingId'); + +describe('addHeadingId', function() { + it('should add an ID if none', function() { + const $ = cheerio.load('<h1>Hello World</h1><h2>Cool !!</h2>'); + + return addHeadingId($) + .then(function() { + const html = $.html(); + expect(html).toBe('<h1 id="hello-world">Hello World</h1><h2 id="cool-">Cool !!</h2>'); + }); + }); + + it('should not change existing IDs', function() { + const $ = cheerio.load('<h1 id="awesome">Hello World</h1>'); + + return addHeadingId($) + .then(function() { + const html = $.html(); + expect(html).toBe('<h1 id="awesome">Hello World</h1>'); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/annotateText.js b/packages/gitbook/src/output/modifiers/__tests__/annotateText.js new file mode 100644 index 0000000..28a5cc5 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/annotateText.js @@ -0,0 +1,45 @@ +const Immutable = require('immutable'); +const cheerio = require('cheerio'); +const GlossaryEntry = require('../../../models/glossaryEntry'); +const annotateText = require('../annotateText'); + +describe('annotateText', function() { + const entries = Immutable.List([ + GlossaryEntry({ name: 'Word' }), + GlossaryEntry({ name: 'Multiple Words' }) + ]); + + it('should annotate text', function() { + const $ = cheerio.load('<p>This is a word, and multiple words</p>'); + + annotateText(entries, 'GLOSSARY.md', $); + + const links = $('a'); + expect(links.length).toBe(2); + + const word = $(links.get(0)); + expect(word.attr('href')).toBe('/GLOSSARY.md#word'); + expect(word.text()).toBe('word'); + expect(word.hasClass('glossary-term')).toBeTruthy(); + + const words = $(links.get(1)); + expect(words.attr('href')).toBe('/GLOSSARY.md#multiple-words'); + expect(words.text()).toBe('multiple words'); + expect(words.hasClass('glossary-term')).toBeTruthy(); + }); + + it('should not annotate scripts', function() { + const $ = cheerio.load('<script>This is a word, and multiple words</script>'); + + annotateText(entries, 'GLOSSARY.md', $); + expect($('a').length).toBe(0); + }); + + it('should not annotate when has class "no-glossary"', function() { + const $ = cheerio.load('<p class="no-glossary">This is a word, and multiple words</p>'); + + annotateText(entries, 'GLOSSARY.md', $); + expect($('a').length).toBe(0); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js b/packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js new file mode 100644 index 0000000..9145cae --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js @@ -0,0 +1,39 @@ +const cheerio = require('cheerio'); +const tmp = require('tmp'); +const path = require('path'); + +const URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png'; + +describe('fetchRemoteImages', function() { + let dir; + const fetchRemoteImages = require('../fetchRemoteImages'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should download image file', function() { + const $ = cheerio.load('<img src="' + URL + '" />'); + + return fetchRemoteImages(dir.name, 'index.html', $) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); + + it('should download image file and replace with relative path', function() { + const $ = cheerio.load('<img src="' + URL + '" />'); + + return fetchRemoteImages(dir.name, 'test/index.html', $) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(path.join('test', src)); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js b/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js new file mode 100644 index 0000000..fd031b0 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js @@ -0,0 +1,24 @@ +const cheerio = require('cheerio'); +const tmp = require('tmp'); +const inlinePng = require('../inlinePng'); + +describe('inlinePng', function() { + let dir; + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should write an inline PNG using data URI as a file', function() { + const $ = cheerio.load('<img alt="GitBook Logo 20x20" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUEAYAAADdGcFOAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAAF+klEQVRIDY3Wf5CVVR3H8c9z791fyI9dQwdQ4TTI7wEWnQZZAa/mJE4Z0OaKUuN1KoaykZxUGGHay+iIVFMoEYrUPhDCKEKW2ChT8dA0RCSxWi6EW3sYYpcfxq5C+4O9957O+7m7O/qHQ9/XzH1+nHuec57z8wkWTsKw0y6N/LxXN6KzTnEUHi8eP/l3YStSU/MdsYvBbGh8six2YXcbcgc++QkfTQkWz/81KtqDA0hlUoWnsX+5uxe5X365BB9my2bjrHNHccLk16BpS9CExjcmXMDbD6wehdyEjxbjz1uK1zn9qga6dcfnMLXeXY/qjuQqTF4W1MKke8ZgeNhjMCxMPIWSd4OF78C55CFI/1kF6WwXpMqjkAZ/CKniNDrCsmU4lE1YbPlgR2x7R39FF23D4mq3A1+Z35PGTNs1E1XhxcGQOh6HNPwXkK56BVJhOaRg/pvoHXNxHFw410B25EYE2RMvI0i/twFJvXcrFObykEa+DmnQGLwYqR0l2a6JqItaj8C/4E2QxtZCofkC8tF1t8HZc/fAZaLnIF2xEsoEtW1w7vBSSFtfhDTnCki9cSi81Ain1uko2Ld+Dmf2rkUq0/5t+PYbFtPQdkjzNiAXTWtDEF49FgkzJInAVPwNyhzcDOmrdZCm/Rn+ebWtcPs+/U24hmg2XL0rRkPPELh9R8fDtXR2oC/VuZbGaci79Ajkb6lZgfyYtyzy/X9s6T/pO/ZfN/RdNxxIwTWM2wbX8KVmuIaEqmKm6zEondwGpd0SyOy5DrJ//TFkX9kMhd3XQHbEVCSsm4OECV5HIv2p15CwfWPSntoHRbv2Q1HzSvSlSqZwATIuBxk/zZBOBbdB+u9hSKU3Q7pwAjInZkFm6U8hu7MSMqe/Dqn8fUj5GVCmpxK+4N/F1LMa0p5eSOPqIPP7NGSunAI/+R6GnzQzIBt8A1LC/QZ+6HwLst1rITv0n5CtXgSZ78yFTNkR+FdeDZneJkip3fAtsQ5Scilkek7CH9dAmjIWvkK7IXXOh6/IzZDNPQdZXR1TQmdjKv0ZfEu0YKDpNflpyG5aDtnRv8VAuu3dBV+huyBbvgdS97tQNLQc0mfugKy5Cb4BipPIXvsUpK5N8Mvao/Bd3QDZRH9Rrtj3Cl6FHwPFMLmNkKrj8BnHoT+XX6f2wl+XxFS4Ab7C72Dgf7bi+5DpTkNm8kQMpCs/BzIlz8LfPxnzLdh3EjwMX4GX4Ju4GNb9A1L7k/D3J8b6kv2LFCtmCmcgUzoJsr2z4MfwFsh87xikZefg188fYaAhpPUxm3ge/vFnYkoED0HqeQiyJYcwkNGWnoNv6s9C1p1Bf/389VYoCjohW7UfMms3wXdpBv7+FEiPLIHs4DIMNERUNhbSpY3wk6QOsqlCDVx2xCrInMpBmfNPQOnzKxBkkrugdOl9GKigSZZCUWIm/GqwDtLUI5D+WAOlb9wKP0YvQLbjZSjsaYaL/n0/FA3fDtnCGihK5UYjCK+ZDr+TDIKLdm2Fs1UOzo76F5wO74XSZj0S6d7RCMLkCshcXALZxaWQRjXDZQ62oRAdCeG/Ju5HELX2QFH3C0hkRy6GovyfwF58AoVbguOxyB2H7/I34Gf11yANnQSp7Vr4MbQH0vg7kbNNp5AM3UrIVDchnz56B1Jm573wW9gZSFVPwO/hefg5FsIvN09CchtQCIOFw/F5U8ii3CZn4cqo7C8YlXEPYkx9cacZl00+iwnprrtwVdj1Q/gXmAs/pu6LZc9XQOGgSvh19n2cDZN341g2EcfxTEGwH/RewqlMsUfbbWIGLjUG+j/j9nokD1beiOvLS5dhjr30Gu6ZnivgdtM/6VJvY1+6pBHbH+h9CX84vfMxNJtisYVFlys+WNCIZJNmIsjohlhNSQC3f8R55H+y/hjkN8GPR9ndCLJxT4/3n0Px51ay8XQnNrYfDJHf//Fc0oMrEZSeeQGJ7+Z+gKCgLbHNWgXnB9FlYt5JaN38JIINC95EakjtAqQeuUx21c5B6tEFf0fSfbEFQf28Z6D6y+X/H0jf40QQJhYwAAAAAElFTkSuQmCC"/>'); + + return inlinePng(dir.name, 'index.html', $) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js b/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js new file mode 100644 index 0000000..d11a31f --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js @@ -0,0 +1,34 @@ +const cheerio = require('cheerio'); +const resolveLinks = require('../resolveLinks'); + +describe('resolveLinks', () => { + function resolveFileBasic(href) { + return 'fakeDir/' + href; + } + + it('should resolve path using resolver', () => { + const TEST = '<p>This is a <a href="test/cool.md"></a></p>'; + const $ = cheerio.load(TEST); + + return resolveLinks(resolveFileBasic, $) + .then(function() { + const link = $('a'); + expect(link.attr('href')).toBe('fakeDir/test/cool.md'); + }); + }); + + describe('External link', () => { + const TEST = '<p>This is a <a href="http://www.github.com">external link</a></p>'; + + it('should have target="_blank" attribute', () => { + const $ = cheerio.load(TEST); + + return resolveLinks(resolveFileBasic, $) + .then(function() { + const link = $('a'); + expect(link.attr('target')).toBe('_blank'); + }); + }); + }); + +}); diff --git a/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js b/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js new file mode 100644 index 0000000..4bdab59 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js @@ -0,0 +1,24 @@ +const cheerio = require('cheerio'); +const tmp = require('tmp'); + +describe('svgToImg', function() { + let dir; + const svgToImg = require('../svgToImg'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should write svg as a file', function() { + const $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>'); + + return svgToImg(dir.name, 'index.html', $) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js b/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js new file mode 100644 index 0000000..0a12938 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js @@ -0,0 +1,32 @@ +const cheerio = require('cheerio'); +const tmp = require('tmp'); +const path = require('path'); + +const svgToImg = require('../svgToImg'); +const svgToPng = require('../svgToPng'); + +describe('svgToPng', function() { + let dir; + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should write svg as png file', function() { + const $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>'); + const fileName = 'index.html'; + + return svgToImg(dir.name, fileName, $) + .then(function() { + return svgToPng(dir.name, fileName, $); + }) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + expect(path.extname(src)).toBe('.png'); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/addHeadingId.js b/packages/gitbook/src/output/modifiers/addHeadingId.js new file mode 100644 index 0000000..e528b9d --- /dev/null +++ b/packages/gitbook/src/output/modifiers/addHeadingId.js @@ -0,0 +1,21 @@ +const slug = require('github-slugid'); +const editHTMLElement = require('./editHTMLElement'); + +/** + * Add ID to an heading. + * @param {HTMLElement} heading + */ +function addId(heading) { + if (heading.attr('id')) return; + heading.attr('id', slug(heading.text())); +} + +/** + * Add ID to all headings. + * @param {HTMLDom} $ + */ +function addHeadingId($) { + return editHTMLElement($, 'h1,h2,h3,h4,h5,h6', addId); +} + +module.exports = addHeadingId; diff --git a/packages/gitbook/src/output/modifiers/annotateText.js b/packages/gitbook/src/output/modifiers/annotateText.js new file mode 100644 index 0000000..36ee4e9 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/annotateText.js @@ -0,0 +1,91 @@ +const escape = require('escape-html'); + +// Selector to ignore +const ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6'; + +function pregQuote(str) { + return (str + '').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1'); +} + +function replaceText($, el, search, replace, text_only) { + return $(el).each(function() { + let node = this.firstChild, val, new_val; + // Elements to be removed at the end. + const remove = []; + + // Only continue if firstChild exists. + if (node) { + + // Loop over all childNodes. + while (node) { + + // Only process text nodes. + if (node.nodeType === 3) { + + // The original node value. + val = node.nodeValue; + + // The new value. + new_val = val.replace(search, replace); + + // Only replace text if the new value is actually different! + if (new_val !== val) { + + if (!text_only && /</.test(new_val)) { + // The new value contains HTML, set it in a slower but far more + // robust way. + $(node).before(new_val); + + // Don't remove the node yet, or the loop will lose its place. + remove.push(node); + } else { + // The new value contains no HTML, so it can be set in this + // very fast, simple way. + node.nodeValue = new_val; + } + } + } + + node = node.nextSibling; + } + } + + // Time to remove those elements! + if (remove.length) $(remove).remove(); + }); +} + +/** + * Annotate text using a list of GlossaryEntry + * + * @param {List<GlossaryEntry>} + * @param {String} glossaryFilePath + * @param {HTMLDom} $ + */ +function annotateText(entries, glossaryFilePath, $) { + entries.forEach(function(entry) { + const entryId = entry.getID(); + const name = entry.getName(); + const description = entry.getDescription(); + const searchRegex = new RegExp('\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi'); + + $('*').each(function() { + const $this = $(this); + + if ( + $this.is(ANNOTATION_IGNORE) || + $this.parents(ANNOTATION_IGNORE).length > 0 + ) return; + + replaceText($, this, searchRegex, function(match) { + return '<a href="/' + glossaryFilePath + '#' + entryId + '" ' + + 'class="glossary-term" title="' + escape(description) + '">' + + match + + '</a>'; + }); + }); + + }); +} + +module.exports = annotateText; diff --git a/packages/gitbook/src/output/modifiers/editHTMLElement.js b/packages/gitbook/src/output/modifiers/editHTMLElement.js new file mode 100644 index 0000000..d0d2b19 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/editHTMLElement.js @@ -0,0 +1,15 @@ +const Promise = require('../../utils/promise'); + +/** + Edit all elements matching a selector +*/ +function editHTMLElement($, selector, fn) { + const $elements = $(selector); + + return Promise.forEach($elements, function(el) { + const $el = $(el); + return fn($el); + }); +} + +module.exports = editHTMLElement; diff --git a/packages/gitbook/src/output/modifiers/fetchRemoteImages.js b/packages/gitbook/src/output/modifiers/fetchRemoteImages.js new file mode 100644 index 0000000..f022093 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/fetchRemoteImages.js @@ -0,0 +1,44 @@ +const path = require('path'); +const crc = require('crc'); + +const editHTMLElement = require('./editHTMLElement'); +const fs = require('../../utils/fs'); +const LocationUtils = require('../../utils/location'); + +/** + * Fetch all remote images + * + * @param {String} rootFolder + * @param {String} currentFile + * @param {HTMLDom} $ + * @return {Promise} + */ +function fetchRemoteImages(rootFolder, currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + let src = $img.attr('src'); + const extension = path.extname(src); + + if (!LocationUtils.isExternal(src)) { + return; + } + + // We avoid generating twice the same PNG + const hash = crc.crc32(src).toString(16); + const fileName = hash + extension; + const filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return fs.download(src, filePath); + }) + .then(function() { + // Convert to relative + src = LocationUtils.relative(currentDirectory, fileName); + + $img.replaceWith('<img src="' + src + '" />'); + }); + }); +} + +module.exports = fetchRemoteImages; diff --git a/packages/gitbook/src/output/modifiers/index.js b/packages/gitbook/src/output/modifiers/index.js new file mode 100644 index 0000000..5f290f6 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/index.js @@ -0,0 +1,14 @@ + +module.exports = { + modifyHTML: require('./modifyHTML'), + inlineAssets: require('./inlineAssets'), + + // HTML transformations + addHeadingId: require('./addHeadingId'), + svgToImg: require('./svgToImg'), + fetchRemoteImages: require('./fetchRemoteImages'), + svgToPng: require('./svgToPng'), + resolveLinks: require('./resolveLinks'), + resolveImages: require('./resolveImages'), + annotateText: require('./annotateText') +}; diff --git a/packages/gitbook/src/output/modifiers/inlineAssets.js b/packages/gitbook/src/output/modifiers/inlineAssets.js new file mode 100644 index 0000000..4541fcc --- /dev/null +++ b/packages/gitbook/src/output/modifiers/inlineAssets.js @@ -0,0 +1,29 @@ +const svgToImg = require('./svgToImg'); +const svgToPng = require('./svgToPng'); +const inlinePng = require('./inlinePng'); +const resolveImages = require('./resolveImages'); +const fetchRemoteImages = require('./fetchRemoteImages'); + +const Promise = require('../../utils/promise'); + +/** + * Inline all assets in a page + * + * @param {String} rootFolder + */ +function inlineAssets(rootFolder, currentFile) { + return function($) { + return Promise() + + // Resolving images and fetching external images should be + // done before svg conversion + .then(resolveImages.bind(null, currentFile, $)) + .then(fetchRemoteImages.bind(null, rootFolder, currentFile, $)) + + .then(svgToImg.bind(null, rootFolder, currentFile, $)) + .then(svgToPng.bind(null, rootFolder, currentFile, $)) + .then(inlinePng.bind(null, rootFolder, currentFile, $)); + }; +} + +module.exports = inlineAssets; diff --git a/packages/gitbook/src/output/modifiers/inlinePng.js b/packages/gitbook/src/output/modifiers/inlinePng.js new file mode 100644 index 0000000..bf14e4f --- /dev/null +++ b/packages/gitbook/src/output/modifiers/inlinePng.js @@ -0,0 +1,46 @@ +const crc = require('crc'); +const path = require('path'); + +const imagesUtil = require('../../utils/images'); +const fs = require('../../utils/fs'); +const LocationUtils = require('../../utils/location'); + +const editHTMLElement = require('./editHTMLElement'); + +/** + * Convert all inline PNG images to PNG file + * + * @param {String} rootFolder + * @param {HTMLDom} $ + * @return {Promise} + */ +function inlinePng(rootFolder, currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + const src = $img.attr('src'); + if (!LocationUtils.isDataURI(src)) { + return; + } + + // We avoid generating twice the same PNG + const hash = crc.crc32(src).toString(16); + let fileName = hash + '.png'; + + // Result file path + const filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return imagesUtil.convertInlinePNG(src, filePath); + }) + .then(function() { + // Convert filename to a relative filename + fileName = LocationUtils.relative(currentDirectory, fileName); + + // Replace src + $img.attr('src', fileName); + }); + }); +} + +module.exports = inlinePng; diff --git a/packages/gitbook/src/output/modifiers/modifyHTML.js b/packages/gitbook/src/output/modifiers/modifyHTML.js new file mode 100644 index 0000000..64abd07 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/modifyHTML.js @@ -0,0 +1,25 @@ +const cheerio = require('cheerio'); +const Promise = require('../../utils/promise'); + +/** + * Apply a list of operations to a page and + * output the new page. + * + * @param {Page} page + * @param {List|Array<Transformation>} operations + * @return {Promise<Page>} page + */ +function modifyHTML(page, operations) { + const html = page.getContent(); + const $ = cheerio.load(html); + + return Promise.forEach(operations, function(op) { + return op($); + }) + .then(function() { + const resultHTML = $.html(); + return page.set('content', resultHTML); + }); +} + +module.exports = modifyHTML; diff --git a/packages/gitbook/src/output/modifiers/resolveImages.js b/packages/gitbook/src/output/modifiers/resolveImages.js new file mode 100644 index 0000000..c647fde --- /dev/null +++ b/packages/gitbook/src/output/modifiers/resolveImages.js @@ -0,0 +1,33 @@ +const path = require('path'); + +const LocationUtils = require('../../utils/location'); +const editHTMLElement = require('./editHTMLElement'); + +/** + * Resolve all HTML images: + * - /test.png in hello -> ../test.html + * + * @param {String} currentFile + * @param {HTMLDom} $ + */ +function resolveImages(currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + let src = $img.attr('src'); + + if (LocationUtils.isExternal(src) || LocationUtils.isDataURI(src)) { + return; + } + + // Calcul absolute path for this + src = LocationUtils.toAbsolute(src, currentDirectory, '.'); + + // Convert back to relative + src = LocationUtils.relative(currentDirectory, src); + + $img.attr('src', src); + }); +} + +module.exports = resolveImages; diff --git a/packages/gitbook/src/output/modifiers/resolveLinks.js b/packages/gitbook/src/output/modifiers/resolveLinks.js new file mode 100644 index 0000000..ca81ccb --- /dev/null +++ b/packages/gitbook/src/output/modifiers/resolveLinks.js @@ -0,0 +1,30 @@ +const LocationUtils = require('../../utils/location'); +const editHTMLElement = require('./editHTMLElement'); + +/** + * Resolve all HTML links: + * - /test.md in hello -> ../test.html + * + * @param {Function(String) -> String} resolveURL + * @param {HTMLDom} $ + */ +function resolveLinks(resolveURL, $) { + return editHTMLElement($, 'a', function($a) { + let href = $a.attr('href'); + + // Don't change a tag without href + if (!href) { + return; + } + + if (LocationUtils.isExternal(href)) { + $a.attr('target', '_blank'); + return; + } + + href = resolveURL(href); + $a.attr('href', href); + }); +} + +module.exports = resolveLinks; diff --git a/packages/gitbook/src/output/modifiers/svgToImg.js b/packages/gitbook/src/output/modifiers/svgToImg.js new file mode 100644 index 0000000..ac37d07 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/svgToImg.js @@ -0,0 +1,56 @@ +const path = require('path'); +const crc = require('crc'); +const domSerializer = require('dom-serializer'); + +const editHTMLElement = require('./editHTMLElement'); +const fs = require('../../utils/fs'); +const LocationUtils = require('../../utils/location'); + +/** + Render a cheerio DOM as html + + @param {HTMLDom} $ + @param {HTMLElement} dom + @param {Object} + @return {String} +*/ +function renderDOM($, dom, options) { + if (!dom && $._root && $._root.children) { + dom = $._root.children; + } + options = options || dom.options || $._options; + return domSerializer(dom, options); +} + +/** + Replace SVG tag by IMG + + @param {String} baseFolder + @param {HTMLDom} $ +*/ +function svgToImg(baseFolder, currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'svg', function($svg) { + const content = '<?xml version="1.0" encoding="UTF-8"?>' + + renderDOM($, $svg); + + // We avoid generating twice the same PNG + const hash = crc.crc32(content).toString(16); + const fileName = hash + '.svg'; + const filePath = path.join(baseFolder, fileName); + + // Write the svg to the file + return fs.assertFile(filePath, function() { + return fs.writeFile(filePath, content, 'utf8'); + }) + + // Return as image + .then(function() { + const src = LocationUtils.relative(currentDirectory, fileName); + $svg.replaceWith('<img src="' + src + '" />'); + }); + }); +} + +module.exports = svgToImg; diff --git a/packages/gitbook/src/output/modifiers/svgToPng.js b/packages/gitbook/src/output/modifiers/svgToPng.js new file mode 100644 index 0000000..ad3f31f --- /dev/null +++ b/packages/gitbook/src/output/modifiers/svgToPng.js @@ -0,0 +1,53 @@ +const crc = require('crc'); +const path = require('path'); + +const imagesUtil = require('../../utils/images'); +const fs = require('../../utils/fs'); +const LocationUtils = require('../../utils/location'); + +const editHTMLElement = require('./editHTMLElement'); + +/** + Convert all SVG images to PNG + + @param {String} rootFolder + @param {HTMLDom} $ + @return {Promise} +*/ +function svgToPng(rootFolder, currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + let src = $img.attr('src'); + if (path.extname(src) !== '.svg') { + return; + } + + // Calcul absolute path for this + src = LocationUtils.toAbsolute(src, currentDirectory, '.'); + + // We avoid generating twice the same PNG + const hash = crc.crc32(src).toString(16); + let fileName = hash + '.png'; + + // Input file path + const inputPath = path.join(rootFolder, src); + + // Result file path + const filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return imagesUtil.convertSVGToPNG(inputPath, filePath); + }) + .then(function() { + // Convert filename to a relative filename + fileName = LocationUtils.relative(currentDirectory, fileName); + + // Replace src + $img.attr('src', fileName); + }); + }); +} + + +module.exports = svgToPng; diff --git a/packages/gitbook/src/output/prepareAssets.js b/packages/gitbook/src/output/prepareAssets.js new file mode 100644 index 0000000..2851b01 --- /dev/null +++ b/packages/gitbook/src/output/prepareAssets.js @@ -0,0 +1,22 @@ +const Parse = require('../parse'); + +/** + * List all assets in the book. + * + * @param {Output} output + * @return {Promise<Output>} output + */ +function prepareAssets(output) { + const book = output.getBook(); + const pages = output.getPages(); + const logger = output.getLogger(); + + return Parse.listAssets(book, pages) + .then(function(assets) { + logger.info.ln('found', assets.size, 'asset files'); + + return output.set('assets', assets); + }); +} + +module.exports = prepareAssets; diff --git a/packages/gitbook/src/output/preparePages.js b/packages/gitbook/src/output/preparePages.js new file mode 100644 index 0000000..0cf1412 --- /dev/null +++ b/packages/gitbook/src/output/preparePages.js @@ -0,0 +1,35 @@ +const Parse = require('../parse'); +const Promise = require('../utils/promise'); +const parseURIIndexFromPages = require('../parse/parseURIIndexFromPages'); + +/** + * List and parse all pages, then create the urls mapping. + * + * @param {Output} + * @return {Promise<Output>} + */ +function preparePages(output) { + const book = output.getBook(); + const logger = book.getLogger(); + const readme = book.getReadme(); + + if (book.isMultilingual()) { + return Promise(output); + } + + return Parse.parsePagesList(book) + .then((pages) => { + logger.info.ln('found', pages.size, 'pages'); + let urls = parseURIIndexFromPages(pages); + + // Readme should always generate an index.html + urls = urls.append(readme.getFile().getPath(), 'index.html'); + + return output.merge({ + pages, + urls + }); + }); +} + +module.exports = preparePages; diff --git a/packages/gitbook/src/output/preparePlugins.js b/packages/gitbook/src/output/preparePlugins.js new file mode 100644 index 0000000..c84bade --- /dev/null +++ b/packages/gitbook/src/output/preparePlugins.js @@ -0,0 +1,36 @@ +const Plugins = require('../plugins'); +const Promise = require('../utils/promise'); + +/** + * Load and setup plugins + * + * @param {Output} + * @return {Promise<Output>} + */ +function preparePlugins(output) { + const book = output.getBook(); + + return Promise() + + // Only load plugins for main book + .then(function() { + if (book.isLanguageBook()) { + return output.getPlugins(); + } else { + return Plugins.loadForBook(book); + } + }) + + // Update book's configuration using the plugins + .then(function(plugins) { + return Plugins.validateConfig(book, plugins) + .then(function(newBook) { + return output.merge({ + book: newBook, + plugins + }); + }); + }); +} + +module.exports = preparePlugins; diff --git a/packages/gitbook/src/output/website/copyPluginAssets.js b/packages/gitbook/src/output/website/copyPluginAssets.js new file mode 100644 index 0000000..fe75377 --- /dev/null +++ b/packages/gitbook/src/output/website/copyPluginAssets.js @@ -0,0 +1,111 @@ +const path = require('path'); + +const ASSET_FOLDER = require('../../constants/pluginAssetsFolder'); +const Promise = require('../../utils/promise'); +const fs = require('../../utils/fs'); + +/** + * Copy all assets from plugins. + * Assets are files stored in a "_assets" of the plugin. + * + * @param {Output} + * @return {Promise} + */ +function copyPluginAssets(output) { + const book = output.getBook(); + + // Don't copy plugins assets for language book + // It'll be resolved to the parent folder + if (book.isLanguageBook()) { + return Promise(output); + } + + const plugins = output.getPlugins(); + + return Promise.forEach(plugins, (plugin) => { + return copyAssets(output, plugin) + .then(() => copyBrowserJS(output, plugin)); + }) + .then(() => copyCoreJS(output)) + .thenResolve(output); +} + +/** + * Copy assets from a plugin + * + * @param {Plugin} + * @return {Promise} + */ +function copyAssets(output, plugin) { + const logger = output.getLogger(); + const pluginRoot = plugin.getPath(); + const options = output.getOptions(); + + const outputRoot = options.get('root'); + const prefix = options.get('prefix'); + + const assetFolder = path.join(pluginRoot, ASSET_FOLDER, prefix); + const assetOutputFolder = path.join(outputRoot, 'gitbook', plugin.getName()); + + if (!fs.existsSync(assetFolder)) { + return Promise(); + } + + logger.debug.ln('copy assets from plugin', assetFolder); + return fs.copyDir( + assetFolder, + assetOutputFolder, + { + deleteFirst: false, + overwrite: true, + confirm: true + } + ); +} + +/** + * Copy JS file for the plugin + * + * @param {Plugin} + * @return {Promise} + */ +function copyBrowserJS(output, plugin) { + const logger = output.getLogger(); + const pluginRoot = plugin.getPath(); + const options = output.getOptions(); + const outputRoot = options.get('root'); + + let browserFile = plugin.getPackage().get('browser'); + + if (!browserFile) { + return Promise(); + } + + browserFile = path.join(pluginRoot, browserFile); + const outputFile = path.join(outputRoot, 'gitbook/plugins', plugin.getName() + '.js'); + + logger.debug.ln('copy browser JS file from plugin', browserFile); + return fs.ensureFile(outputFile) + .then(() => fs.copy(browserFile, outputFile)); +} + +/** + * Copy JS file for gitbook-core + * + * @param {Plugin} + * @return {Promise} + */ +function copyCoreJS(output) { + const logger = output.getLogger(); + const options = output.getOptions(); + const outputRoot = options.get('root'); + + const inputFile = require.resolve('gitbook-core/dist/gitbook.core.min.js'); + const outputFile = path.join(outputRoot, 'gitbook/core.js'); + + logger.debug.ln('copy JS for gitbook-core'); + return fs.ensureFile(outputFile) + .then(() => fs.copy(inputFile, outputFile)); +} + +module.exports = copyPluginAssets; diff --git a/packages/gitbook/src/output/website/index.js b/packages/gitbook/src/output/website/index.js new file mode 100644 index 0000000..c6031e1 --- /dev/null +++ b/packages/gitbook/src/output/website/index.js @@ -0,0 +1,10 @@ + +module.exports = { + name: 'website', + State: require('./state'), + Options: require('./options'), + onInit: require('./onInit'), + onFinish: require('./onFinish'), + onPage: require('./onPage'), + onAsset: require('./onAsset') +}; diff --git a/packages/gitbook/src/output/website/onAsset.js b/packages/gitbook/src/output/website/onAsset.js new file mode 100644 index 0000000..b72c47d --- /dev/null +++ b/packages/gitbook/src/output/website/onAsset.js @@ -0,0 +1,29 @@ +const path = require('path'); +const fs = require('../../utils/fs'); + +/** + * Copy an asset from the book to the output folder. + * + * @param {Output} output + * @param {Page} page + * @return {Output} output + */ +function onAsset(output, asset) { + const book = output.getBook(); + const options = output.getOptions(); + const bookFS = book.getContentFS(); + + const outputFolder = options.get('root'); + const outputPath = path.resolve(outputFolder, asset); + + return fs.ensureFile(outputPath) + .then(function() { + return bookFS.readAsStream(asset) + .then(function(stream) { + return fs.writeStream(outputPath, stream); + }); + }) + .thenResolve(output); +} + +module.exports = onAsset; diff --git a/packages/gitbook/src/output/website/onFinish.js b/packages/gitbook/src/output/website/onFinish.js new file mode 100644 index 0000000..6efeed8 --- /dev/null +++ b/packages/gitbook/src/output/website/onFinish.js @@ -0,0 +1,30 @@ +const JSONUtils = require('../../json'); +const Promise = require('../../utils/promise'); +const writeFile = require('../helper/writeFile'); +const render = require('../../browser/render'); + +/** + * Finish the generation, write the languages index. + * + * @param {Output} + * @return {Output} + */ +function onFinish(output) { + const book = output.getBook(); + + if (!book.isMultilingual()) { + return Promise(output); + } + + const plugins = output.getPlugins(); + + // Generate initial state + const initialState = JSONUtils.encodeState(output); + + // Render using React + const html = render(plugins, initialState, 'browser', 'website:languages'); + + return writeFile(output, 'index.html', html); +} + +module.exports = onFinish; diff --git a/packages/gitbook/src/output/website/onInit.js b/packages/gitbook/src/output/website/onInit.js new file mode 100644 index 0000000..b13c719 --- /dev/null +++ b/packages/gitbook/src/output/website/onInit.js @@ -0,0 +1,15 @@ +const Promise = require('../../utils/promise'); +const copyPluginAssets = require('./copyPluginAssets'); + +/** + * Initialize the generator + * + * @param {Output} + * @return {Output} + */ +function onInit(output) { + return Promise(output) + .then(copyPluginAssets); +} + +module.exports = onInit; diff --git a/packages/gitbook/src/output/website/onPage.js b/packages/gitbook/src/output/website/onPage.js new file mode 100644 index 0000000..90eec63 --- /dev/null +++ b/packages/gitbook/src/output/website/onPage.js @@ -0,0 +1,34 @@ +const JSONUtils = require('../../json'); +const Modifiers = require('../modifiers'); +const writeFile = require('../helper/writeFile'); +const getModifiers = require('../getModifiers'); +const render = require('../../browser/render'); + +/** + * Generate a page using react and the plugins. + * + * @param {Output} output + * @param {Page} page + */ +function onPage(output, page) { + const file = page.getFile(); + const plugins = output.getPlugins(); + const urls = output.getURLIndex(); + + // Output file path + const filePath = urls.resolve(file.getPath()); + + return Modifiers.modifyHTML(page, getModifiers(output, page)) + .then(function(resultPage) { + // Generate the context + const initialState = JSONUtils.encodeState(output, resultPage); + + // Render the theme + const html = render(plugins, initialState, 'browser', 'website:body'); + + // Write it to the disk + return writeFile(output, filePath, html); + }); +} + +module.exports = onPage; diff --git a/packages/gitbook/src/output/website/options.js b/packages/gitbook/src/output/website/options.js new file mode 100644 index 0000000..3bcbd9a --- /dev/null +++ b/packages/gitbook/src/output/website/options.js @@ -0,0 +1,10 @@ +const Immutable = require('immutable'); + +const Options = Immutable.Record({ + // Root folder for the output + root: String(), + // Prefix for generation + prefix: String('website') +}); + +module.exports = Options; diff --git a/packages/gitbook/src/output/website/state.js b/packages/gitbook/src/output/website/state.js new file mode 100644 index 0000000..2adb9ed --- /dev/null +++ b/packages/gitbook/src/output/website/state.js @@ -0,0 +1,18 @@ +const I18n = require('i18n-t'); +const Immutable = require('immutable'); + +const GeneratorState = Immutable.Record({ + i18n: I18n(), + // List of plugins' resources + resources: Immutable.Map() +}); + +GeneratorState.prototype.getI18n = function() { + return this.get('i18n'); +}; + +GeneratorState.prototype.getResources = function() { + return this.get('resources'); +}; + +module.exports = GeneratorState; diff --git a/packages/gitbook/src/parse/__tests__/listAssets.js b/packages/gitbook/src/parse/__tests__/listAssets.js new file mode 100644 index 0000000..102aed9 --- /dev/null +++ b/packages/gitbook/src/parse/__tests__/listAssets.js @@ -0,0 +1,29 @@ +const Immutable = require('immutable'); + +const Book = require('../../models/book'); +const createMockFS = require('../../fs/mock'); +const listAssets = require('../listAssets'); +const parseGlossary = require('../parseGlossary'); + +describe('listAssets', function() { + it('should not list glossary as asset', function() { + const fs = createMockFS({ + 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello', + 'assetFile.js': '', + 'assets': { + 'file.js': '' + } + }); + const book = Book.createForFS(fs); + + return parseGlossary(book) + .then(function(resultBook) { + return listAssets(resultBook, Immutable.Map()); + }) + .then(function(assets) { + expect(assets.size).toBe(2); + expect(assets.includes('assetFile.js')); + expect(assets.includes('assets/file.js')); + }); + }); +}); diff --git a/packages/gitbook/src/parse/__tests__/parseBook.js b/packages/gitbook/src/parse/__tests__/parseBook.js new file mode 100644 index 0000000..d5de25c --- /dev/null +++ b/packages/gitbook/src/parse/__tests__/parseBook.js @@ -0,0 +1,90 @@ +const Book = require('../../models/book'); +const createMockFS = require('../../fs/mock'); + +describe('parseBook', function() { + const parseBook = require('../parseBook'); + + it('should parse multilingual book', function() { + const fs = createMockFS({ + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'en': { + 'README.md': 'Hello' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }); + const book = Book.createForFS(fs); + + return parseBook(book) + .then(function(resultBook) { + const languages = resultBook.getLanguages(); + const books = resultBook.getBooks(); + + expect(resultBook.isMultilingual()).toBe(true); + expect(languages.getList().size).toBe(2); + expect(books.size).toBe(2); + }); + }); + + it('should extend configuration for multilingual book', function() { + const fs = createMockFS({ + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'book.json': '{ "title": "Test", "author": "GitBook" }', + 'en': { + 'README.md': 'Hello', + 'book.json': '{ "title": "Test EN" }' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }); + const book = Book.createForFS(fs); + + return parseBook(book) + .then(function(resultBook) { + const books = resultBook.getBooks(); + + expect(resultBook.isMultilingual()).toBe(true); + expect(books.size).toBe(2); + + const en = books.get('en'); + const fr = books.get('fr'); + + const enConfig = en.getConfig(); + const frConfig = fr.getConfig(); + + expect(enConfig.getValue('title')).toBe('Test EN'); + expect(enConfig.getValue('author')).toBe('GitBook'); + + expect(frConfig.getValue('title')).toBe('Test'); + expect(frConfig.getValue('author')).toBe('GitBook'); + }); + }); + + it('should parse book in a directory', function() { + const fs = createMockFS({ + 'book.json': JSON.stringify({ + root: './test' + }), + 'test': { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page](page.md)\n', + 'page.md': 'Page' + } + }); + const book = Book.createForFS(fs); + + return parseBook(book) + .then(function(resultBook) { + const readme = resultBook.getReadme(); + const summary = resultBook.getSummary(); + const articles = summary.getArticlesAsList(); + + expect(summary.getFile().exists()).toBe(true); + expect(readme.getFile().exists()).toBe(true); + expect(articles.size).toBe(2); + }); + }); + +}); diff --git a/packages/gitbook/src/parse/__tests__/parseGlossary.js b/packages/gitbook/src/parse/__tests__/parseGlossary.js new file mode 100644 index 0000000..ba2e407 --- /dev/null +++ b/packages/gitbook/src/parse/__tests__/parseGlossary.js @@ -0,0 +1,36 @@ +const Book = require('../../models/book'); +const createMockFS = require('../../fs/mock'); + +describe('parseGlossary', function() { + const parseGlossary = require('../parseGlossary'); + + it('should parse glossary if exists', function() { + const fs = createMockFS({ + 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello' + }); + const book = Book.createForFS(fs); + + return parseGlossary(book) + .then(function(resultBook) { + const glossary = resultBook.getGlossary(); + const file = glossary.getFile(); + const entries = glossary.getEntries(); + + expect(file.exists()).toBeTruthy(); + expect(entries.size).toBe(1); + }); + }); + + it('should not fail if doesn\'t exist', function() { + const fs = createMockFS({}); + const book = Book.createForFS(fs); + + return parseGlossary(book) + .then(function(resultBook) { + const glossary = resultBook.getGlossary(); + const file = glossary.getFile(); + + expect(file.exists()).toBeFalsy(); + }); + }); +}); diff --git a/packages/gitbook/src/parse/__tests__/parseIgnore.js b/packages/gitbook/src/parse/__tests__/parseIgnore.js new file mode 100644 index 0000000..b1bd43c --- /dev/null +++ b/packages/gitbook/src/parse/__tests__/parseIgnore.js @@ -0,0 +1,40 @@ +const Book = require('../../models/book'); +const createMockFS = require('../../fs/mock'); + +describe('parseIgnore', function() { + const parseIgnore = require('../parseIgnore'); + const fs = createMockFS({ + '.ignore': 'test-1.js', + '.gitignore': 'test-2.js\ntest-3.js', + '.bookignore': '!test-3.js', + 'test-1.js': '1', + 'test-2.js': '2', + 'test-3.js': '3' + }); + + function getBook() { + const book = Book.createForFS(fs); + return parseIgnore(book); + } + + it('should load rules from .ignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-1.js')).toBeTruthy(); + }); + }); + + it('should load rules from .gitignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-2.js')).toBeTruthy(); + }); + }); + + it('should load rules from .bookignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-3.js')).toBeFalsy(); + }); + }); +}); diff --git a/packages/gitbook/src/parse/__tests__/parsePageFromString.js b/packages/gitbook/src/parse/__tests__/parsePageFromString.js new file mode 100644 index 0000000..13bc544 --- /dev/null +++ b/packages/gitbook/src/parse/__tests__/parsePageFromString.js @@ -0,0 +1,37 @@ +const parsePageFromString = require('../parsePageFromString'); +const Page = require('../../models/page'); + +describe('parsePageFromString', function() { + const page = new Page(); + + it('should parse YAML frontmatter', function() { + const CONTENT = '---\nhello: true\nworld: "cool"\n---\n# Hello World\n'; + const newPage = parsePageFromString(page, CONTENT); + + expect(newPage.getDir()).toBe('ltr'); + expect(newPage.getContent()).toBe('# Hello World\n'); + + const attrs = newPage.getAttributes(); + expect(attrs.size).toBe(2); + expect(attrs.get('hello')).toBe(true); + expect(attrs.get('world')).toBe('cool'); + }); + + it('should parse text direction (english)', function() { + const CONTENT = 'Hello World'; + const newPage = parsePageFromString(page, CONTENT); + + expect(newPage.getDir()).toBe('ltr'); + expect(newPage.getContent()).toBe('Hello World'); + expect(newPage.getAttributes().size).toBe(0); + }); + + it('should parse text direction (arab)', function() { + const CONTENT = 'مرحبا بالعالم'; + const newPage = parsePageFromString(page, CONTENT); + + expect(newPage.getDir()).toBe('rtl'); + expect(newPage.getContent()).toBe('مرحبا بالعالم'); + expect(newPage.getAttributes().size).toBe(0); + }); +}); diff --git a/packages/gitbook/src/parse/__tests__/parseReadme.js b/packages/gitbook/src/parse/__tests__/parseReadme.js new file mode 100644 index 0000000..45ecfa3 --- /dev/null +++ b/packages/gitbook/src/parse/__tests__/parseReadme.js @@ -0,0 +1,36 @@ +const Promise = require('../../utils/promise'); +const Book = require('../../models/book'); +const createMockFS = require('../../fs/mock'); + +describe('parseReadme', function() { + const parseReadme = require('../parseReadme'); + + it('should parse summary if exists', function() { + const fs = createMockFS({ + 'README.md': '# Hello\n\nAnd here is the description.' + }); + const book = Book.createForFS(fs); + + return parseReadme(book) + .then(function(resultBook) { + const readme = resultBook.getReadme(); + const file = readme.getFile(); + + expect(file.exists()).toBeTruthy(); + expect(readme.getTitle()).toBe('Hello'); + expect(readme.getDescription()).toBe('And here is the description.'); + }); + }); + + it('should fail if doesn\'t exist', function() { + const fs = createMockFS({}); + const book = Book.createForFS(fs); + + return parseReadme(book) + .then(function(resultBook) { + throw new Error('It should have fail'); + }, function() { + return Promise(); + }); + }); +}); diff --git a/packages/gitbook/src/parse/__tests__/parseSummary.js b/packages/gitbook/src/parse/__tests__/parseSummary.js new file mode 100644 index 0000000..8b86c45 --- /dev/null +++ b/packages/gitbook/src/parse/__tests__/parseSummary.js @@ -0,0 +1,34 @@ +const Book = require('../../models/book'); +const createMockFS = require('../../fs/mock'); + +describe('parseSummary', function() { + const parseSummary = require('../parseSummary'); + + it('should parse summary if exists', function() { + const fs = createMockFS({ + 'SUMMARY.md': '# Summary\n\n* [Hello](hello.md)' + }); + const book = Book.createForFS(fs); + + return parseSummary(book) + .then(function(resultBook) { + const summary = resultBook.getSummary(); + const file = summary.getFile(); + + expect(file.exists()).toBeTruthy(); + }); + }); + + it('should not fail if doesn\'t exist', function() { + const fs = createMockFS({}); + const book = Book.createForFS(fs); + + return parseSummary(book) + .then(function(resultBook) { + const summary = resultBook.getSummary(); + const file = summary.getFile(); + + expect(file.exists()).toBeFalsy(); + }); + }); +}); diff --git a/packages/gitbook/src/parse/__tests__/parseURIIndexFromPages.js b/packages/gitbook/src/parse/__tests__/parseURIIndexFromPages.js new file mode 100644 index 0000000..755b225 --- /dev/null +++ b/packages/gitbook/src/parse/__tests__/parseURIIndexFromPages.js @@ -0,0 +1,26 @@ +const { OrderedMap } = require('immutable'); + +const parseURIIndexFromPages = require('../parseURIIndexFromPages'); +const Page = require('../../models/page'); + +describe('parseURIIndexFromPages', () => { + + it('should map file to html', () => { + const pages = OrderedMap({ + 'page.md': new Page() + }); + const urls = parseURIIndexFromPages(pages); + + expect(urls.resolve('page.md')).toBe('page.html'); + }); + + it('should map README to folder', () => { + const pages = OrderedMap({ + 'hello/README.md': new Page() + }); + const urls = parseURIIndexFromPages(pages); + + expect(urls.resolveToURL('hello/README.md')).toBe('hello/'); + }); + +}); diff --git a/packages/gitbook/src/parse/findParsableFile.js b/packages/gitbook/src/parse/findParsableFile.js new file mode 100644 index 0000000..c30dbbd --- /dev/null +++ b/packages/gitbook/src/parse/findParsableFile.js @@ -0,0 +1,36 @@ +const path = require('path'); + +const Promise = require('../utils/promise'); +const parsers = require('../parsers'); + +/** + Find a file parsable (Markdown or AsciiDoc) in a book + + @param {Book} book + @param {String} filename + @return {Promise<File | Undefined>} +*/ +function findParsableFile(book, filename) { + const fs = book.getContentFS(); + const ext = path.extname(filename); + const basename = path.basename(filename, ext); + const basedir = path.dirname(filename); + + // Ordered list of extensions to test + const exts = parsers.extensions; + + return Promise.some(exts, function(ext) { + const filepath = basename + ext; + + return fs.findFile(basedir, filepath) + .then(function(found) { + if (!found || book.isContentFileIgnored(found)) { + return undefined; + } + + return fs.statFile(found); + }); + }); +} + +module.exports = findParsableFile; diff --git a/packages/gitbook/src/parse/index.js b/packages/gitbook/src/parse/index.js new file mode 100644 index 0000000..1f73946 --- /dev/null +++ b/packages/gitbook/src/parse/index.js @@ -0,0 +1,15 @@ + +module.exports = { + parseBook: require('./parseBook'), + parseSummary: require('./parseSummary'), + parseGlossary: require('./parseGlossary'), + parseReadme: require('./parseReadme'), + parseConfig: require('./parseConfig'), + parsePagesList: require('./parsePagesList'), + parseIgnore: require('./parseIgnore'), + listAssets: require('./listAssets'), + parseLanguages: require('./parseLanguages'), + parsePage: require('./parsePage'), + parsePageFromString: require('./parsePageFromString'), + lookupStructureFile: require('./lookupStructureFile') +}; diff --git a/packages/gitbook/src/parse/listAssets.js b/packages/gitbook/src/parse/listAssets.js new file mode 100644 index 0000000..91699df --- /dev/null +++ b/packages/gitbook/src/parse/listAssets.js @@ -0,0 +1,43 @@ +const timing = require('../utils/timing'); + +/** + * List all assets in a book + * Assets are file not ignored and not a page + * + * @param {Book} book + * @param {List<String>} pages + * @return {Promise<List<String>>} assets + */ +function listAssets(book, pages) { + const fs = book.getContentFS(); + + const summary = book.getSummary(); + const summaryFile = summary.getFile().getPath(); + + const glossary = book.getGlossary(); + const glossaryFile = glossary.getFile().getPath(); + + const langs = book.getLanguages(); + const langsFile = langs.getFile().getPath(); + + const config = book.getConfig(); + const configFile = config.getFile().getPath(); + + function filterFile(file) { + return !( + file === summaryFile || + file === glossaryFile || + file === langsFile || + file === configFile || + book.isContentFileIgnored(file) || + pages.has(file) + ); + } + + return timing.measure( + 'parse.listAssets', + fs.listAllFiles('.', filterFile) + ); +} + +module.exports = listAssets; diff --git a/packages/gitbook/src/parse/lookupStructureFile.js b/packages/gitbook/src/parse/lookupStructureFile.js new file mode 100644 index 0000000..e54a769 --- /dev/null +++ b/packages/gitbook/src/parse/lookupStructureFile.js @@ -0,0 +1,20 @@ +const findParsableFile = require('./findParsableFile'); + +/** + Lookup a structure file (ex: SUMMARY.md, GLOSSARY.md) in a book. Uses + book's config to find it. + + @param {Book} book + @param {String} type: one of ["glossary", "readme", "summary", "langs"] + @return {Promise<File | Undefined>} The path of the file found, relative + to the book content root. +*/ +function lookupStructureFile(book, type) { + const config = book.getConfig(); + + const fileToSearch = config.getValue(['structure', type]); + + return findParsableFile(book, fileToSearch); +} + +module.exports = lookupStructureFile; diff --git a/packages/gitbook/src/parse/parseBook.js b/packages/gitbook/src/parse/parseBook.js new file mode 100644 index 0000000..e5c1784 --- /dev/null +++ b/packages/gitbook/src/parse/parseBook.js @@ -0,0 +1,77 @@ +const Promise = require('../utils/promise'); +const timing = require('../utils/timing'); +const Book = require('../models/book'); + +const parseIgnore = require('./parseIgnore'); +const parseConfig = require('./parseConfig'); +const parseGlossary = require('./parseGlossary'); +const parseSummary = require('./parseSummary'); +const parseReadme = require('./parseReadme'); +const parseLanguages = require('./parseLanguages'); + +/** + * Parse content of a book + * + * @param {Book} book + * @return {Promise<Book>} + */ +function parseBookContent(book) { + return Promise(book) + .then(parseReadme) + .then(parseSummary) + .then(parseGlossary); +} + +/** + * Parse a multilingual book + * + * @param {Book} book + * @return {Promise<Book>} + */ +function parseMultilingualBook(book) { + const languages = book.getLanguages(); + const langList = languages.getList(); + + return Promise.reduce(langList, function(currentBook, lang) { + const langID = lang.getID(); + const child = Book.createFromParent(currentBook, langID); + let ignore = currentBook.getIgnore(); + + return Promise(child) + .then(parseConfig) + .then(parseBookContent) + .then(function(result) { + // Ignore content of this book when generating parent book + ignore = ignore.add(langID + '/**'); + currentBook = currentBook.set('ignore', ignore); + + return currentBook.addLanguageBook(langID, result); + }); + }, book); +} + + +/** + * Parse a whole book from a filesystem + * + * @param {Book} book + * @return {Promise<Book>} + */ +function parseBook(book) { + return timing.measure( + 'parse.book', + Promise(book) + .then(parseIgnore) + .then(parseConfig) + .then(parseLanguages) + .then(function(resultBook) { + if (resultBook.isMultilingual()) { + return parseMultilingualBook(resultBook); + } else { + return parseBookContent(resultBook); + } + }) + ); +} + +module.exports = parseBook; diff --git a/packages/gitbook/src/parse/parseConfig.js b/packages/gitbook/src/parse/parseConfig.js new file mode 100644 index 0000000..cd27426 --- /dev/null +++ b/packages/gitbook/src/parse/parseConfig.js @@ -0,0 +1,55 @@ +const Promise = require('../utils/promise'); + +const validateConfig = require('./validateConfig'); +const CONFIG_FILES = require('../constants/configFiles'); + +/** + Parse configuration from "book.json" or "book.js" + + @param {Book} book + @return {Promise<Book>} +*/ +function parseConfig(book) { + const fs = book.getFS(); + let config = book.getConfig(); + + return Promise.some(CONFIG_FILES, function(filename) { + // Is this file ignored? + if (book.isFileIgnored(filename)) { + return; + } + + // Try loading it + return fs.loadAsObject(filename) + .then(function(cfg) { + return fs.statFile(filename) + .then(function(file) { + return { + file, + values: cfg + }; + }); + }) + .fail(function(err) { + if (err.code != 'MODULE_NOT_FOUND') throw (err); + else return Promise(false); + }); + }) + + .then(function(result) { + let values = result ? result.values : {}; + values = validateConfig(values); + + // Set the file + if (result.file) { + config = config.setFile(result.file); + } + + // Merge with old values + config = config.mergeValues(values); + + return book.setConfig(config); + }); +} + +module.exports = parseConfig; diff --git a/packages/gitbook/src/parse/parseGlossary.js b/packages/gitbook/src/parse/parseGlossary.js new file mode 100644 index 0000000..052985b --- /dev/null +++ b/packages/gitbook/src/parse/parseGlossary.js @@ -0,0 +1,26 @@ +const parseStructureFile = require('./parseStructureFile'); +const Glossary = require('../models/glossary'); + +/** + Parse glossary + + @param {Book} book + @return {Promise<Book>} +*/ +function parseGlossary(book) { + const logger = book.getLogger(); + + return parseStructureFile(book, 'glossary') + .spread(function(file, entries) { + if (!file) { + return book; + } + + logger.debug.ln('glossary index file found at', file.getPath()); + + const glossary = Glossary.createFromEntries(file, entries); + return book.set('glossary', glossary); + }); +} + +module.exports = parseGlossary; diff --git a/packages/gitbook/src/parse/parseIgnore.js b/packages/gitbook/src/parse/parseIgnore.js new file mode 100644 index 0000000..a42805b --- /dev/null +++ b/packages/gitbook/src/parse/parseIgnore.js @@ -0,0 +1,54 @@ +const Promise = require('../utils/promise'); +const IGNORE_FILES = require('../constants/ignoreFiles'); + +const DEFAULT_IGNORES = [ + // Skip Git stuff + '.git/', + + // Skip OS X meta data + '.DS_Store', + + // Skip stuff installed by plugins + 'node_modules', + + // Skip book outputs + '_book', + + // Ignore files in the templates folder + '_layouts' +]; + +/** + * Parse ignore files + * + * @param {Book} book + * @return {Book} book + */ +function parseIgnore(book) { + if (book.isLanguageBook()) { + return Promise.reject(new Error('Ignore files could be parsed for language books')); + } + + const fs = book.getFS(); + let ignore = book.getIgnore(); + + ignore = ignore.add(DEFAULT_IGNORES); + + return Promise.serie(IGNORE_FILES, (filename) => { + return fs.readAsString(filename) + .then( + (content) => { + ignore = ignore.add(content.toString().split(/\r?\n/)); + }, + (err) => { + return Promise(); + } + ); + }) + + .then(() => { + return book.setIgnore(ignore); + }); +} + +module.exports = parseIgnore; diff --git a/packages/gitbook/src/parse/parseLanguages.js b/packages/gitbook/src/parse/parseLanguages.js new file mode 100644 index 0000000..1b28930 --- /dev/null +++ b/packages/gitbook/src/parse/parseLanguages.js @@ -0,0 +1,28 @@ +const parseStructureFile = require('./parseStructureFile'); +const Languages = require('../models/languages'); + +/** + Parse languages list from book + + @param {Book} book + @return {Promise<Book>} +*/ +function parseLanguages(book) { + const logger = book.getLogger(); + + return parseStructureFile(book, 'langs') + .spread(function(file, result) { + if (!file) { + return book; + } + + const languages = Languages.createFromList(file, result); + + logger.debug.ln('languages index file found at', file.getPath()); + logger.info.ln('parsing multilingual book, with', languages.getList().size, 'languages'); + + return book.set('languages', languages); + }); +} + +module.exports = parseLanguages; diff --git a/packages/gitbook/src/parse/parsePage.js b/packages/gitbook/src/parse/parsePage.js new file mode 100644 index 0000000..72f9ddf --- /dev/null +++ b/packages/gitbook/src/parse/parsePage.js @@ -0,0 +1,21 @@ +const parsePageFromString = require('./parsePageFromString'); + +/** + * Parse a page, read its content and parse the YAMl header + * + * @param {Book} book + * @param {Page} page + * @return {Promise<Page>} + */ +function parsePage(book, page) { + const fs = book.getContentFS(); + const file = page.getFile(); + + return fs.readAsString(file.getPath()) + .then(function(content) { + return parsePageFromString(page, content); + }); +} + + +module.exports = parsePage; diff --git a/packages/gitbook/src/parse/parsePageFromString.js b/packages/gitbook/src/parse/parsePageFromString.js new file mode 100644 index 0000000..2e4a598 --- /dev/null +++ b/packages/gitbook/src/parse/parsePageFromString.js @@ -0,0 +1,22 @@ +const Immutable = require('immutable'); +const fm = require('front-matter'); +const direction = require('direction'); + +/** + * Parse a page, its content and the YAMl header + * + * @param {Page} page + * @return {Page} + */ +function parsePageFromString(page, content) { + const parsed = fm(content); + + return page.merge({ + content: parsed.body, + attributes: Immutable.fromJS(parsed.attributes), + dir: direction(parsed.body) + }); +} + + +module.exports = parsePageFromString; diff --git a/packages/gitbook/src/parse/parsePagesList.js b/packages/gitbook/src/parse/parsePagesList.js new file mode 100644 index 0000000..89a1a4f --- /dev/null +++ b/packages/gitbook/src/parse/parsePagesList.js @@ -0,0 +1,97 @@ +const Immutable = require('immutable'); + +const timing = require('../utils/timing'); +const Page = require('../models/page'); +const walkSummary = require('./walkSummary'); +const parsePage = require('./parsePage'); + + +/** + * Parse a page from a path + * + * @param {Book} book + * @param {String} filePath + * @return {Page?} + */ +function parseFilePage(book, filePath) { + const fs = book.getContentFS(); + + return fs.statFile(filePath) + .then( + function(file) { + const page = Page.createForFile(file); + return parsePage(book, page); + }, + function(err) { + // file doesn't exist + return null; + } + ) + .fail(function(err) { + const logger = book.getLogger(); + logger.error.ln('error while parsing page "' + filePath + '":'); + throw err; + }); +} + + +/** + * Parse all pages from a book as an OrderedMap + * + * @param {Book} book + * @return {Promise<OrderedMap<Page>>} + */ +function parsePagesList(book) { + const summary = book.getSummary(); + const glossary = book.getGlossary(); + let map = Immutable.OrderedMap(); + + // Parse pages from summary + return timing.measure( + 'parse.listPages', + walkSummary(summary, function(article) { + if (!article.isPage()) return; + + const filepath = article.getPath(); + + // Is the page ignored? + if (book.isContentFileIgnored(filepath)) return; + + return parseFilePage(book, filepath) + .then(function(page) { + // file doesn't exist + if (!page) { + return; + } + + map = map.set(filepath, page); + }); + }) + ) + + // Parse glossary + .then(function() { + const file = glossary.getFile(); + + if (!file.exists()) { + return; + } + + return parseFilePage(book, file.getPath()) + .then(function(page) { + // file doesn't exist + if (!page) { + return; + } + + map = map.set(file.getPath(), page); + }); + }) + + .then(function() { + return map; + }); +} + + +module.exports = parsePagesList; diff --git a/packages/gitbook/src/parse/parseReadme.js b/packages/gitbook/src/parse/parseReadme.js new file mode 100644 index 0000000..82f8f19 --- /dev/null +++ b/packages/gitbook/src/parse/parseReadme.js @@ -0,0 +1,28 @@ +const parseStructureFile = require('./parseStructureFile'); +const Readme = require('../models/readme'); + +const error = require('../utils/error'); + +/** + Parse readme from book + + @param {Book} book + @return {Promise<Book>} +*/ +function parseReadme(book) { + const logger = book.getLogger(); + + return parseStructureFile(book, 'readme') + .spread(function(file, result) { + if (!file) { + throw new error.FileNotFoundError({ filename: 'README' }); + } + + logger.debug.ln('readme found at', file.getPath()); + + const readme = Readme.create(file, result); + return book.set('readme', readme); + }); +} + +module.exports = parseReadme; diff --git a/packages/gitbook/src/parse/parseStructureFile.js b/packages/gitbook/src/parse/parseStructureFile.js new file mode 100644 index 0000000..951da96 --- /dev/null +++ b/packages/gitbook/src/parse/parseStructureFile.js @@ -0,0 +1,67 @@ +const Promise = require('../utils/promise'); +const error = require('../utils/error'); +const lookupStructureFile = require('./lookupStructureFile'); + +/** + Parse a ParsableFile using a specific method + + @param {FS} fs + @param {ParsableFile} file + @param {String} type + @return {Promise<Array<String, List|Map>>} +*/ +function parseFile(fs, file, type) { + const filepath = file.getPath(); + const parser = file.getParser(); + + if (!parser) { + return Promise.reject( + error.FileNotParsableError({ + filename: filepath + }) + ); + } + + return fs.readAsString(filepath) + .then(function(content) { + if (type === 'readme') { + return parser.parseReadme(content); + } else if (type === 'glossary') { + return parser.parseGlossary(content); + } else if (type === 'summary') { + return parser.parseSummary(content); + } else if (type === 'langs') { + return parser.parseLanguages(content); + } else { + throw new Error('Parsing invalid type "' + type + '"'); + } + }) + .then(function(result) { + return [ + file, + result + ]; + }); +} + + +/** + Parse a structure file (ex: SUMMARY.md, GLOSSARY.md). + It uses the configuration to find the specified file. + + @param {Book} book + @param {String} type: one of ["glossary", "readme", "summary"] + @return {Promise<List|Map>} +*/ +function parseStructureFile(book, type) { + const fs = book.getContentFS(); + + return lookupStructureFile(book, type) + .then(function(file) { + if (!file) return [undefined, undefined]; + + return parseFile(fs, file, type); + }); +} + +module.exports = parseStructureFile; diff --git a/packages/gitbook/src/parse/parseSummary.js b/packages/gitbook/src/parse/parseSummary.js new file mode 100644 index 0000000..9488341 --- /dev/null +++ b/packages/gitbook/src/parse/parseSummary.js @@ -0,0 +1,44 @@ +const parseStructureFile = require('./parseStructureFile'); +const Summary = require('../models/summary'); +const SummaryModifier = require('../modifiers').Summary; + +/** + Parse summary in a book, the summary can only be parsed + if the readme as be detected before. + + @param {Book} book + @return {Promise<Book>} +*/ +function parseSummary(book) { + const readme = book.getReadme(); + const logger = book.getLogger(); + const readmeFile = readme.getFile(); + + return parseStructureFile(book, 'summary') + .spread(function(file, result) { + let summary; + + if (!file) { + logger.warn.ln('no summary file in this book'); + summary = Summary(); + } else { + logger.debug.ln('summary file found at', file.getPath()); + summary = Summary.createFromParts(file, result.parts); + } + + // Insert readme as first entry if not in SUMMARY.md + const readmeArticle = summary.getByPath(readmeFile.getPath()); + + if (readmeFile.exists() && !readmeArticle) { + summary = SummaryModifier.unshiftArticle(summary, { + title: 'Introduction', + ref: readmeFile.getPath() + }); + } + + // Set new summary + return book.setSummary(summary); + }); +} + +module.exports = parseSummary; diff --git a/packages/gitbook/src/parse/parseURIIndexFromPages.js b/packages/gitbook/src/parse/parseURIIndexFromPages.js new file mode 100644 index 0000000..645d083 --- /dev/null +++ b/packages/gitbook/src/parse/parseURIIndexFromPages.js @@ -0,0 +1,44 @@ +const path = require('path'); +const PathUtils = require('../utils/path'); +const LocationUtils = require('../utils/location'); +const URIIndex = require('../models/uriIndex'); + +const OUTPUT_EXTENSION = '.html'; + +/** + * Convert a filePath (absolute) to an url (without hostname). + * It returns an absolute path. + * + * "README.md" -> "/index.html" + * "test/hello.md" -> "test/hello.html" + * "test/README.md" -> "test/index.html" + * + * @param {Output} output + * @param {String} filePath + * @return {String} + */ +function fileToURL(filePath) { + if ( + path.basename(filePath, path.extname(filePath)) == 'README' + ) { + filePath = path.join(path.dirname(filePath), 'index' + OUTPUT_EXTENSION); + } else { + filePath = PathUtils.setExtension(filePath, OUTPUT_EXTENSION); + } + + return LocationUtils.normalize(filePath); +} + +/** + * Parse a set of pages into an URIIndex. + * Each pages is added as an entry in the index. + * + * @param {OrderedMap<Page>} pages + * @return {URIIndex} index + */ +function parseURIIndexFromPages(pages) { + const urls = pages.map((page, filePath) => fileToURL(filePath)); + return new URIIndex(urls); +} + +module.exports = parseURIIndexFromPages; diff --git a/packages/gitbook/src/parse/validateConfig.js b/packages/gitbook/src/parse/validateConfig.js new file mode 100644 index 0000000..e766fae --- /dev/null +++ b/packages/gitbook/src/parse/validateConfig.js @@ -0,0 +1,31 @@ +const jsonschema = require('jsonschema'); +const jsonSchemaDefaults = require('json-schema-defaults'); + +const schema = require('../constants/configSchema'); +const error = require('../utils/error'); +const mergeDefaults = require('../utils/mergeDefaults'); + +/** + Validate a book.json content + And return a mix with the default value + + @param {Object} bookJson + @return {Object} +*/ +function validateConfig(bookJson) { + const v = new jsonschema.Validator(); + const result = v.validate(bookJson, schema, { + propertyName: 'config' + }); + + // Throw error + if (result.errors.length > 0) { + throw new error.ConfigurationError(new Error(result.errors[0].stack)); + } + + // Insert default values + const defaults = jsonSchemaDefaults(schema); + return mergeDefaults(bookJson, defaults); +} + +module.exports = validateConfig; diff --git a/packages/gitbook/src/parse/walkSummary.js b/packages/gitbook/src/parse/walkSummary.js new file mode 100644 index 0000000..47feb1f --- /dev/null +++ b/packages/gitbook/src/parse/walkSummary.js @@ -0,0 +1,34 @@ +const Promise = require('../utils/promise'); + +/** + Walk over a list of articles + + @param {List<Article>} articles + @param {Function(article)} + @return {Promise} +*/ +function walkArticles(articles, fn) { + return Promise.forEach(articles, function(article) { + return Promise(fn(article)) + .then(function() { + return walkArticles(article.getArticles(), fn); + }); + }); +} + +/** + Walk over summary and execute "fn" on each article + + @param {Summary} summary + @param {Function(article)} + @return {Promise} +*/ +function walkSummary(summary, fn) { + const parts = summary.getParts(); + + return Promise.forEach(parts, function(part) { + return walkArticles(part.getArticles(), fn); + }); +} + +module.exports = walkSummary; diff --git a/packages/gitbook/src/parsers.js b/packages/gitbook/src/parsers.js new file mode 100644 index 0000000..62c3776 --- /dev/null +++ b/packages/gitbook/src/parsers.js @@ -0,0 +1,63 @@ +const path = require('path'); +const Immutable = require('immutable'); + +const markdownParser = require('gitbook-markdown'); +const asciidocParser = require('gitbook-asciidoc'); + +const EXTENSIONS_MARKDOWN = require('./constants/extsMarkdown'); +const EXTENSIONS_ASCIIDOC = require('./constants/extsAsciidoc'); +const Parser = require('./models/parser'); + +// This list is ordered by priority of parsers to use +const parsers = Immutable.List([ + Parser.create('markdown', EXTENSIONS_MARKDOWN, markdownParser), + Parser.create('asciidoc', EXTENSIONS_ASCIIDOC, asciidocParser) +]); + +/** + * Return a specific parser by its name + * + * @param {String} name + * @return {Parser|undefined} + */ +function getParser(name) { + return parsers.find(function(parser) { + return parser.getName() === name; + }); +} + +/** + * Return a specific parser according to an extension + * + * @param {String} ext + * @return {Parser|undefined} + */ +function getParserByExt(ext) { + return parsers.find(function(parser) { + return parser.matchExtension(ext); + }); +} + +/** + * Return parser for a file + * + * @param {String} ext + * @return {Parser|undefined} + */ +function getParserForFile(filename) { + return getParserByExt(path.extname(filename)); +} + +// List all parsable extensions +const extensions = parsers + .map(function(parser) { + return parser.getExtensions(); + }) + .flatten(); + +module.exports = { + extensions, + get: getParser, + getByExt: getParserByExt, + getForFile: getParserForFile +}; diff --git a/packages/gitbook/src/plugins/__tests__/findForBook.js b/packages/gitbook/src/plugins/__tests__/findForBook.js new file mode 100644 index 0000000..0d12aa1 --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/findForBook.js @@ -0,0 +1,19 @@ +const path = require('path'); + +const Book = require('../../models/book'); +const createNodeFS = require('../../fs/node'); +const findForBook = require('../findForBook'); + +describe('findForBook', () => { + const fs = createNodeFS( + path.resolve(__dirname, '../../..') + ); + const book = Book.createForFS(fs); + + it('should list default plugins', () => { + return findForBook(book) + .then((plugins) => { + expect(plugins.has('theme-default')).toBeTruthy(); + }); + }); +}); diff --git a/packages/gitbook/src/plugins/__tests__/findInstalled.js b/packages/gitbook/src/plugins/__tests__/findInstalled.js new file mode 100644 index 0000000..e787761 --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/findInstalled.js @@ -0,0 +1,25 @@ +const path = require('path'); +const Immutable = require('immutable'); + +describe('findInstalled', function() { + const findInstalled = require('../findInstalled'); + + it('must list default plugins for gitbook directory', function() { + // Read gitbook-plugins from package.json + const pkg = require(path.resolve(__dirname, '../../../package.json')); + const gitbookPlugins = Immutable.Seq(pkg.dependencies) + .filter(function(v, k) { + return k.indexOf('gitbook-plugin') === 0; + }) + .cacheResult(); + + return findInstalled(path.resolve(__dirname, '../../../')) + .then(function(plugins) { + expect(plugins.size >= gitbookPlugins.size).toBeTruthy(); + + expect(plugins.has('highlight')).toBe(true); + expect(plugins.has('search')).toBe(true); + }); + }); + +}); diff --git a/packages/gitbook/src/plugins/__tests__/installPlugin.js b/packages/gitbook/src/plugins/__tests__/installPlugin.js new file mode 100644 index 0000000..97f1475 --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/installPlugin.js @@ -0,0 +1,42 @@ +const tmp = require('tmp'); + +const PluginDependency = require('../../models/pluginDependency'); +const Book = require('../../models/book'); +const NodeFS = require('../../fs/node'); +const installPlugin = require('../installPlugin'); + +const Parse = require('../../parse'); + +describe('installPlugin', () => { + let book, dir; + + before(() => { + dir = tmp.dirSync({ unsafeCleanup: true }); + const fs = NodeFS(dir.name); + const baseBook = Book.createForFS(fs) + .setLogLevel('disabled'); + + return Parse.parseConfig(baseBook) + .then((_book) => { + book = _book; + }); + }); + + after(() => { + dir.removeCallback(); + }); + + it('must install a plugin from NPM', () => { + const dep = PluginDependency.createFromString('ga'); + return installPlugin(book, dep) + .then(() => { + expect(dir.name).toHaveFile('node_modules/gitbook-plugin-ga/package.json'); + expect(dir.name).toNotHaveFile('package.json'); + }); + }); + + it('must install a specific version of a plugin', () => { + const dep = PluginDependency.createFromString('ga@0.2.1'); + return installPlugin(book, dep); + }); +}); diff --git a/packages/gitbook/src/plugins/__tests__/installPlugins.js b/packages/gitbook/src/plugins/__tests__/installPlugins.js new file mode 100644 index 0000000..26f135d --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/installPlugins.js @@ -0,0 +1,37 @@ +const tmp = require('tmp'); + +const Book = require('../../models/book'); +const MockFS = require('../../fs/mock'); +const installPlugins = require('../installPlugins'); + +const Parse = require('../../parse'); + +describe('installPlugins', () => { + let book, dir; + + before(() => { + dir = tmp.dirSync({ unsafeCleanup: true }); + + const fs = MockFS({ + 'book.json': JSON.stringify({ plugins: ['ga', 'sitemap' ]}) + }, dir.name); + const baseBook = Book.createForFS(fs) + .setLogLevel('disabled'); + + return Parse.parseConfig(baseBook) + .then((_book) => { + book = _book; + }); + }); + + after(() => { + dir.removeCallback(); + }); + + it('must install all plugins from NPM', () => { + return installPlugins(book) + .then(function(n) { + expect(n).toBe(2); + }); + }); +}); diff --git a/packages/gitbook/src/plugins/__tests__/listDependencies.js b/packages/gitbook/src/plugins/__tests__/listDependencies.js new file mode 100644 index 0000000..002f0e9 --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/listDependencies.js @@ -0,0 +1,39 @@ +const PluginDependency = require('../../models/pluginDependency'); +const listDependencies = require('../listDependencies'); +const toNames = require('../toNames'); + +describe('listDependencies', () => { + it('must list default', () => { + const deps = PluginDependency.listFromString('ga,great'); + const plugins = listDependencies(deps); + const names = toNames(plugins); + + expect(names).toEqual([ + 'ga', 'great', 'highlight', 'search', 'lunr', + 'sharing', 'hints', 'headings', 'copy-code', 'theme-default' + ]); + }); + + it('must list from array with -', () => { + const deps = PluginDependency.listFromString('ga,-great'); + const plugins = listDependencies(deps); + const names = toNames(plugins); + + expect(names).toEqual([ + 'ga', 'highlight', 'search', 'lunr', + 'sharing', 'hints', 'headings', + 'copy-code', 'theme-default' + ]); + }); + + it('must remove default plugins using -', () => { + const deps = PluginDependency.listFromString('ga,-search'); + const plugins = listDependencies(deps); + const names = toNames(plugins); + + expect(names).toEqual([ + 'ga', 'highlight', 'lunr', 'sharing', + 'hints', 'headings', 'copy-code', 'theme-default' + ]); + }); +}); diff --git a/packages/gitbook/src/plugins/__tests__/locateRootFolder.js b/packages/gitbook/src/plugins/__tests__/locateRootFolder.js new file mode 100644 index 0000000..54e095b --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/locateRootFolder.js @@ -0,0 +1,10 @@ +const path = require('path'); +const locateRootFolder = require('../locateRootFolder'); + +describe('locateRootFolder', function() { + it('should correctly resolve the node_modules for gitbook', function() { + expect(locateRootFolder()).toBe( + path.resolve(__dirname, '../../../') + ); + }); +}); diff --git a/packages/gitbook/src/plugins/__tests__/resolveVersion.js b/packages/gitbook/src/plugins/__tests__/resolveVersion.js new file mode 100644 index 0000000..949d078 --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/resolveVersion.js @@ -0,0 +1,22 @@ +const PluginDependency = require('../../models/pluginDependency'); +const resolveVersion = require('../resolveVersion'); + +describe('resolveVersion', function() { + it('must skip resolving and return non-semver versions', function() { + const plugin = PluginDependency.createFromString('ga@git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + + return resolveVersion(plugin) + .then(function(version) { + expect(version).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + }); + }); + + it('must resolve a normal plugin dependency', function() { + const plugin = PluginDependency.createFromString('ga@>0.9.0 < 1.0.1'); + + return resolveVersion(plugin) + .then(function(version) { + expect(version).toBe('1.0.0'); + }); + }); +}); diff --git a/packages/gitbook/src/plugins/__tests__/sortDependencies.js b/packages/gitbook/src/plugins/__tests__/sortDependencies.js new file mode 100644 index 0000000..a08d59d --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/sortDependencies.js @@ -0,0 +1,42 @@ +const PluginDependency = require('../../models/pluginDependency'); +const sortDependencies = require('../sortDependencies'); +const toNames = require('../toNames'); + +describe('sortDependencies', function() { + it('must load themes after plugins', function() { + const allPlugins = PluginDependency.listFromArray([ + 'hello', + 'theme-test', + 'world' + ]); + + const sorted = sortDependencies(allPlugins); + const names = toNames(sorted); + + expect(names).toEqual([ + 'hello', + 'world', + 'theme-test' + ]); + }); + + it('must keep order of themes', function() { + const allPlugins = PluginDependency.listFromArray([ + 'theme-test', + 'theme-test1', + 'hello', + 'theme-test2', + 'world' + ]); + const sorted = sortDependencies(allPlugins); + const names = toNames(sorted); + + expect(names).toEqual([ + 'hello', + 'world', + 'theme-test', + 'theme-test1', + 'theme-test2' + ]); + }); +}); diff --git a/packages/gitbook/src/plugins/__tests__/validatePlugin.js b/packages/gitbook/src/plugins/__tests__/validatePlugin.js new file mode 100644 index 0000000..a2bd23b --- /dev/null +++ b/packages/gitbook/src/plugins/__tests__/validatePlugin.js @@ -0,0 +1,16 @@ +const Promise = require('../../utils/promise'); +const Plugin = require('../../models/plugin'); +const validatePlugin = require('../validatePlugin'); + +describe('validatePlugin', function() { + it('must not validate a not loaded plugin', function() { + const plugin = Plugin.createFromString('test'); + + return validatePlugin(plugin) + .then(function() { + throw new Error('Should not be validate'); + }, function(err) { + return Promise(); + }); + }); +}); diff --git a/packages/gitbook/src/plugins/findForBook.js b/packages/gitbook/src/plugins/findForBook.js new file mode 100644 index 0000000..8668d1d --- /dev/null +++ b/packages/gitbook/src/plugins/findForBook.js @@ -0,0 +1,33 @@ +const { List, OrderedMap } = require('immutable'); + +const Promise = require('../utils/promise'); +const timing = require('../utils/timing'); +const findInstalled = require('./findInstalled'); +const locateRootFolder = require('./locateRootFolder'); + +/** + * List all plugins installed in a book + * + * @param {Book} + * @return {Promise<OrderedMap<String:Plugin>>} + */ +function findForBook(book) { + return timing.measure( + 'plugins.findForBook', + + Promise.all([ + findInstalled(locateRootFolder()), + findInstalled(book.getRoot()) + ]) + + // Merge all plugins + .then(function(results) { + return List(results) + .reduce(function(out, result) { + return out.merge(result); + }, OrderedMap()); + }) + ); +} + +module.exports = findForBook; diff --git a/packages/gitbook/src/plugins/findInstalled.js b/packages/gitbook/src/plugins/findInstalled.js new file mode 100644 index 0000000..fb690c2 --- /dev/null +++ b/packages/gitbook/src/plugins/findInstalled.js @@ -0,0 +1,81 @@ +const { OrderedMap } = require('immutable'); +const path = require('path'); + +const Promise = require('../utils/promise'); +const fs = require('../utils/fs'); +const Plugin = require('../models/plugin'); +const PREFIX = require('../constants/pluginPrefix'); + +/** + * Validate if a package name is a GitBook plugin + * + * @return {Boolean} + */ +function validateId(name) { + return name && name.indexOf(PREFIX) === 0; +} + +/** + * Read details about a node module. + * @param {String} modulePath + * @param {Number} depth + * @param {String} parent + * @return {Plugin} plugin + */ +function readModule(modulePath, depth, parent) { + const pkg = require(path.join(modulePath, 'package.json')); + const pluginName = pkg.name.slice(PREFIX.length); + + return new Plugin({ + name: pluginName, + version: pkg.version, + path: modulePath, + depth, + parent + }); +} + +/** + * List all packages installed inside a folder + * + * @param {String} folder + * @param {Number} depth + * @param {String} parent + * @return {Promise<OrderedMap<String:Plugin>>} plugins + */ +function findInstalled(folder, depth = 0, parent = null) { + // When tetsing with mock-fs + if (!folder) { + return Promise(OrderedMap()); + } + + // Search for gitbook-plugins in node_modules folder + const node_modules = path.join(folder, 'node_modules'); + + // List all folders in node_modules + return fs.readdir(node_modules) + .fail(() => { + return Promise([]); + }) + .then((modules) => { + return Promise.reduce(modules, (results, moduleName) => { + // Not a gitbook-plugin + if (!validateId(moduleName)) { + return results; + } + + // Read gitbook-plugin package details + const moduleFolder = path.join(node_modules, moduleName); + const plugin = readModule(moduleFolder, depth, parent); + + results = results.set(plugin.getName(), plugin); + + return findInstalled(moduleFolder, depth + 1, plugin.getName()) + .then((innerModules) => { + return results.merge(innerModules); + }); + }, OrderedMap()); + }); +} + +module.exports = findInstalled; diff --git a/packages/gitbook/src/plugins/index.js b/packages/gitbook/src/plugins/index.js new file mode 100644 index 0000000..bdc3b05 --- /dev/null +++ b/packages/gitbook/src/plugins/index.js @@ -0,0 +1,8 @@ + +module.exports = { + loadForBook: require('./loadForBook'), + validateConfig: require('./validateConfig'), + installPlugins: require('./installPlugins'), + listBlocks: require('./listBlocks'), + listFilters: require('./listFilters') +}; diff --git a/packages/gitbook/src/plugins/installPlugin.js b/packages/gitbook/src/plugins/installPlugin.js new file mode 100644 index 0000000..9834d05 --- /dev/null +++ b/packages/gitbook/src/plugins/installPlugin.js @@ -0,0 +1,44 @@ +const resolve = require('resolve'); + +const { exec } = require('../utils/command'); +const resolveVersion = require('./resolveVersion'); + +/** + * Install a plugin for a book + * + * @param {Book} book + * @param {PluginDependency} plugin + * @return {Promise} + */ +function installPlugin(book, plugin) { + const logger = book.getLogger(); + + const installFolder = book.getRoot(); + const name = plugin.getName(); + const requirement = plugin.getVersion(); + + logger.info.ln(''); + logger.info.ln('installing plugin "' + name + '"'); + + const installerBin = resolve.sync('ied/lib/cmd.js'); + + // Find a version to install + return resolveVersion(plugin) + .then(function(version) { + if (!version) { + throw new Error('Found no satisfactory version for plugin "' + name + '" with requirement "' + requirement + '"'); + } + + logger.info.ln('install plugin "' + name + '" (' + requirement + ') with version', version); + + const npmID = plugin.getNpmID(); + const command = `${installerBin} install ${npmID}@${version}`; + + return exec(command, { cwd: installFolder }); + }) + .then(function() { + logger.info.ok('plugin "' + name + '" installed with success'); + }); +} + +module.exports = installPlugin; diff --git a/packages/gitbook/src/plugins/installPlugins.js b/packages/gitbook/src/plugins/installPlugins.js new file mode 100644 index 0000000..9d2520f --- /dev/null +++ b/packages/gitbook/src/plugins/installPlugins.js @@ -0,0 +1,46 @@ +const DEFAULT_PLUGINS = require('../constants/defaultPlugins'); +const Promise = require('../utils/promise'); +const installPlugin = require('./installPlugin'); + +/** + * Install plugin requirements for a book + * + * @param {Book} book + * @return {Promise<Number>} count + */ +function installPlugins(book) { + const logger = book.getLogger(); + const config = book.getConfig(); + let plugins = config.getPluginDependencies(); + + // Remove default plugins + // (only if version is same as installed) + plugins = plugins.filterNot(function(plugin) { + const dependency = DEFAULT_PLUGINS.find(function(dep) { + return dep.getName() === plugin.getName(); + }); + + return ( + // Disabled plugin + !plugin.isEnabled() || + + // Or default one installed in GitBook itself + (dependency && + plugin.getVersion() === dependency.getVersion()) + ); + }); + + if (plugins.size == 0) { + logger.info.ln('nothing to install!'); + return Promise(0); + } + + logger.info.ln('installing', plugins.size, 'plugins from registry'); + + return Promise.forEach(plugins, function(plugin) { + return installPlugin(book, plugin); + }) + .thenResolve(plugins.size); +} + +module.exports = installPlugins; diff --git a/packages/gitbook/src/plugins/listBlocks.js b/packages/gitbook/src/plugins/listBlocks.js new file mode 100644 index 0000000..a2b04f5 --- /dev/null +++ b/packages/gitbook/src/plugins/listBlocks.js @@ -0,0 +1,21 @@ +const { Map } = require('immutable'); + +/** + * List blocks from a list of plugins + * + * @param {OrderedMap<String:Plugin>} + * @return {Map<String:TemplateBlock>} + */ +function listBlocks(plugins) { + return plugins + .reverse() + .reduce( + (result, plugin) => { + const blocks = plugin.getBlocks(); + return result.merge(blocks); + }, + Map() + ); +} + +module.exports = listBlocks; diff --git a/packages/gitbook/src/plugins/listDependencies.js b/packages/gitbook/src/plugins/listDependencies.js new file mode 100644 index 0000000..3930ae7 --- /dev/null +++ b/packages/gitbook/src/plugins/listDependencies.js @@ -0,0 +1,33 @@ +const DEFAULT_PLUGINS = require('../constants/defaultPlugins'); +const sortDependencies = require('./sortDependencies'); + +/** + * List all dependencies for a book, including default plugins. + * It returns a concat with default plugins and remove disabled ones. + * + * @param {List<PluginDependency>} deps + * @return {List<PluginDependency>} + */ +function listDependencies(deps) { + // Extract list of plugins to disable (starting with -) + const toRemove = deps + .filter(function(plugin) { + return !plugin.isEnabled(); + }) + .map(function(plugin) { + return plugin.getName(); + }); + + // Concat with default plugins + deps = deps.concat(DEFAULT_PLUGINS); + + // Remove plugins + deps = deps.filterNot(function(plugin) { + return toRemove.includes(plugin.getName()); + }); + + // Sort + return sortDependencies(deps); +} + +module.exports = listDependencies; diff --git a/packages/gitbook/src/plugins/listDepsForBook.js b/packages/gitbook/src/plugins/listDepsForBook.js new file mode 100644 index 0000000..81f619d --- /dev/null +++ b/packages/gitbook/src/plugins/listDepsForBook.js @@ -0,0 +1,18 @@ +const listDependencies = require('./listDependencies'); + +/** + * List all plugin requirements for a book. + * It can be different from the final list of plugins, + * since plugins can have their own dependencies + * + * @param {Book} book + * @return {List<PluginDependency>} dependencies + */ +function listDepsForBook(book) { + const config = book.getConfig(); + const plugins = config.getPluginDependencies(); + + return listDependencies(plugins); +} + +module.exports = listDepsForBook; diff --git a/packages/gitbook/src/plugins/listFilters.js b/packages/gitbook/src/plugins/listFilters.js new file mode 100644 index 0000000..57d5c29 --- /dev/null +++ b/packages/gitbook/src/plugins/listFilters.js @@ -0,0 +1,20 @@ +const { Map } = require('immutable'); + +/** + * List filters from a list of plugins + * + * @param {OrderedMap<String:Plugin>} plugins + * @return {Map<String:Function>} filters + */ +function listFilters(plugins) { + return plugins + .reverse() + .reduce( + (result, plugin) => { + return result.merge(plugin.getFilters()); + }, + Map() + ); +} + +module.exports = listFilters; diff --git a/packages/gitbook/src/plugins/loadForBook.js b/packages/gitbook/src/plugins/loadForBook.js new file mode 100644 index 0000000..0baa78e --- /dev/null +++ b/packages/gitbook/src/plugins/loadForBook.js @@ -0,0 +1,73 @@ +const Immutable = require('immutable'); + +const Promise = require('../utils/promise'); +const listDepsForBook = require('./listDepsForBook'); +const findForBook = require('./findForBook'); +const loadPlugin = require('./loadPlugin'); + + +/** + * Load all plugins in a book + * + * @param {Book} + * @return {Promise<Map<String:Plugin>} + */ +function loadForBook(book) { + const logger = book.getLogger(); + + // List the dependencies + const requirements = listDepsForBook(book); + + // List all plugins installed in the book + return findForBook(book) + .then(function(installedMap) { + const missing = []; + let plugins = requirements.reduce(function(result, dep) { + const name = dep.getName(); + const installed = installedMap.get(name); + + if (installed) { + const deps = installedMap + .filter(function(plugin) { + return plugin.getParent() === name; + }) + .toArray(); + + result = result.concat(deps); + result.push(installed); + } else { + missing.push(name); + } + + return result; + }, []); + + // Convert plugins list to a map + plugins = Immutable.List(plugins) + .map(function(plugin) { + return [ + plugin.getName(), + plugin + ]; + }); + plugins = Immutable.OrderedMap(plugins); + + // Log state + logger.info.ln(installedMap.size + ' plugins are installed'); + if (requirements.size != installedMap.size) { + logger.info.ln(requirements.size + ' explicitly listed'); + } + + // Verify that all plugins are present + if (missing.length > 0) { + throw new Error('Couldn\'t locate plugins "' + missing.join(', ') + '", Run \'gitbook install\' to install plugins from registry.'); + } + + return Promise.map(plugins, function(plugin) { + return loadPlugin(book, plugin); + }); + }); +} + + +module.exports = loadForBook; diff --git a/packages/gitbook/src/plugins/loadPlugin.js b/packages/gitbook/src/plugins/loadPlugin.js new file mode 100644 index 0000000..167587a --- /dev/null +++ b/packages/gitbook/src/plugins/loadPlugin.js @@ -0,0 +1,89 @@ +const path = require('path'); +const resolve = require('resolve'); +const Immutable = require('immutable'); + +const Promise = require('../utils/promise'); +const error = require('../utils/error'); +const timing = require('../utils/timing'); + +const validatePlugin = require('./validatePlugin'); + +// Return true if an error is a "module not found" +// Wait on https://github.com/substack/node-resolve/pull/81 to be merged +function isModuleNotFound(err) { + return err.code == 'MODULE_NOT_FOUND' || err.message.indexOf('Cannot find module') >= 0; +} + +/** + * Load a plugin in a book + * + * @param {Book} book + * @param {Plugin} plugin + * @param {String} pkgPath (optional) + * @return {Promise<Plugin>} + */ +function loadPlugin(book, plugin) { + const logger = book.getLogger(); + + const name = plugin.getName(); + let pkgPath = plugin.getPath(); + + // Try loading plugins from different location + let p = Promise() + .then(function() { + let packageContent; + let packageMain; + let content; + + // Locate plugin and load package.json + try { + const res = resolve.sync('./package.json', { basedir: pkgPath }); + + pkgPath = path.dirname(res); + packageContent = require(res); + } catch (err) { + if (!isModuleNotFound(err)) throw err; + + packageContent = undefined; + content = undefined; + + return; + } + + // Locate the main package + try { + const indexJs = path.normalize(packageContent.main || 'index.js'); + packageMain = resolve.sync('./' + indexJs, { basedir: pkgPath }); + } catch (err) { + if (!isModuleNotFound(err)) throw err; + packageMain = undefined; + } + + // Load plugin JS content + if (packageMain) { + try { + content = require(packageMain); + } catch (err) { + throw new error.PluginError(err, { + plugin: name + }); + } + } + + // Update plugin + return plugin.merge({ + 'package': Immutable.fromJS(packageContent), + 'content': Immutable.fromJS(content || {}) + }); + }) + + .then(validatePlugin); + + p = timing.measure('plugin.load', p); + + logger.info('loading plugin "' + name + '"... '); + return logger.info.promise(p); +} + + +module.exports = loadPlugin; diff --git a/packages/gitbook/src/plugins/locateRootFolder.js b/packages/gitbook/src/plugins/locateRootFolder.js new file mode 100644 index 0000000..64e06a8 --- /dev/null +++ b/packages/gitbook/src/plugins/locateRootFolder.js @@ -0,0 +1,22 @@ +const path = require('path'); +const resolve = require('resolve'); + +const DEFAULT_PLUGINS = require('../constants/defaultPlugins'); + +/** + * Resolve the root folder containing for node_modules + * since gitbook can be used as a library and dependency can be flattened. + * + * @return {String} folderPath + */ +function locateRootFolder() { + const firstDefaultPlugin = DEFAULT_PLUGINS.first(); + const pluginPath = resolve.sync(firstDefaultPlugin.getNpmID() + '/package.json', { + basedir: __dirname + }); + const nodeModules = path.resolve(pluginPath, '../../..'); + + return nodeModules; +} + +module.exports = locateRootFolder; diff --git a/packages/gitbook/src/plugins/resolveVersion.js b/packages/gitbook/src/plugins/resolveVersion.js new file mode 100644 index 0000000..a241c23 --- /dev/null +++ b/packages/gitbook/src/plugins/resolveVersion.js @@ -0,0 +1,70 @@ +const npm = require('npm'); +const semver = require('semver'); +const { Map } = require('immutable'); + +const Promise = require('../utils/promise'); +const Plugin = require('../models/plugin'); +const gitbook = require('../gitbook'); + +let npmIsReady; + +/** + * Initialize and prepare NPM + * @return {Promise} + */ +function initNPM() { + if (npmIsReady) return npmIsReady; + + npmIsReady = Promise.nfcall(npm.load, { + silent: true, + loglevel: 'silent' + }); + + return npmIsReady; +} + +/** + * Resolve a plugin dependency to a version + * + * @param {PluginDependency} plugin + * @return {Promise<String>} + */ +function resolveVersion(plugin) { + const npmId = Plugin.nameToNpmID(plugin.getName()); + const requiredVersion = plugin.getVersion(); + + if (plugin.isGitDependency()) { + return Promise.resolve(requiredVersion); + } + + return initNPM() + .then(function() { + return Promise.nfcall(npm.commands.view, [npmId + '@' + requiredVersion, 'engines'], true); + }) + .then(function(versions) { + versions = Map(versions).entrySeq(); + + const result = versions + .map(function(entry) { + return { + version: entry[0], + gitbook: (entry[1].engines || {}).gitbook + }; + }) + .filter(function(v) { + return v.gitbook && gitbook.satisfies(v.gitbook); + }) + .sort(function(v1, v2) { + return semver.lt(v1.version, v2.version) ? 1 : -1; + }) + .get(0); + + if (!result) { + return undefined; + } else { + return result.version; + } + }); +} + +module.exports = resolveVersion; diff --git a/packages/gitbook/src/plugins/sortDependencies.js b/packages/gitbook/src/plugins/sortDependencies.js new file mode 100644 index 0000000..2adfa20 --- /dev/null +++ b/packages/gitbook/src/plugins/sortDependencies.js @@ -0,0 +1,34 @@ +const Immutable = require('immutable'); + +const THEME_PREFIX = require('../constants/themePrefix'); + +const TYPE_PLUGIN = 'plugin'; +const TYPE_THEME = 'theme'; + + +/** + * Returns the type of a plugin given its name + * @param {Plugin} plugin + * @return {String} + */ +function pluginType(plugin) { + const name = plugin.getName(); + return (name && name.indexOf(THEME_PREFIX) === 0) ? TYPE_THEME : TYPE_PLUGIN; +} + + +/** + * Sort the list of dependencies to match list in book.json + * The themes should always be loaded after the plugins + * + * @param {List<PluginDependency>} deps + * @return {List<PluginDependency>} + */ +function sortDependencies(plugins) { + const byTypes = plugins.groupBy(pluginType); + + return byTypes.get(TYPE_PLUGIN, Immutable.List()) + .concat(byTypes.get(TYPE_THEME, Immutable.List())); +} + +module.exports = sortDependencies; diff --git a/packages/gitbook/src/plugins/toNames.js b/packages/gitbook/src/plugins/toNames.js new file mode 100644 index 0000000..422a24d --- /dev/null +++ b/packages/gitbook/src/plugins/toNames.js @@ -0,0 +1,16 @@ + +/** + * Return list of plugin names. This method is only used in unit tests. + * + * @param {OrderedMap<String:Plugin} plugins + * @return {Array<String>} + */ +function toNames(plugins) { + return plugins + .map(function(plugin) { + return plugin.getName(); + }) + .toArray(); +} + +module.exports = toNames; diff --git a/packages/gitbook/src/plugins/validateConfig.js b/packages/gitbook/src/plugins/validateConfig.js new file mode 100644 index 0000000..82a2507 --- /dev/null +++ b/packages/gitbook/src/plugins/validateConfig.js @@ -0,0 +1,71 @@ +const Immutable = require('immutable'); +const jsonschema = require('jsonschema'); +const jsonSchemaDefaults = require('json-schema-defaults'); + +const Promise = require('../utils/promise'); +const error = require('../utils/error'); +const mergeDefaults = require('../utils/mergeDefaults'); + +/** + * Validate one plugin for a book and update book's confiration + * + * @param {Book} + * @param {Plugin} + * @return {Book} + */ +function validatePluginConfig(book, plugin) { + let config = book.getConfig(); + const packageInfos = plugin.getPackage(); + + const configKey = [ + 'pluginsConfig', + plugin.getName() + ].join('.'); + + let pluginConfig = config.getValue(configKey, {}).toJS(); + + const schema = (packageInfos.get('gitbook') || Immutable.Map()).toJS(); + if (!schema) return book; + + // Normalize schema + schema.id = '/' + configKey; + schema.type = 'object'; + + // Validate and throw if invalid + const v = new jsonschema.Validator(); + const result = v.validate(pluginConfig, schema, { + propertyName: configKey + }); + + // Throw error + if (result.errors.length > 0) { + throw new error.ConfigurationError(new Error(result.errors[0].stack)); + } + + // Insert default values + const defaults = jsonSchemaDefaults(schema); + pluginConfig = mergeDefaults(pluginConfig, defaults); + + + // Update configuration + config = config.setValue(configKey, pluginConfig); + + // Return new book + return book.set('config', config); +} + +/** + * Validate a book configuration for plugins and + * returns an update configuration with default values. + * + * @param {Book} + * @param {OrderedMap<String:Plugin>} + * @return {Promise<Book>} + */ +function validateConfig(book, plugins) { + return Promise.reduce(plugins, function(newBook, plugin) { + return validatePluginConfig(newBook, plugin); + }, book); +} + +module.exports = validateConfig; diff --git a/packages/gitbook/src/plugins/validatePlugin.js b/packages/gitbook/src/plugins/validatePlugin.js new file mode 100644 index 0000000..cc9ac7b --- /dev/null +++ b/packages/gitbook/src/plugins/validatePlugin.js @@ -0,0 +1,34 @@ +const gitbook = require('../gitbook'); + +const Promise = require('../utils/promise'); + +/** + * Validate a plugin + * + * @param {Plugin} + * @return {Promise<Plugin>} + */ +function validatePlugin(plugin) { + const packageInfos = plugin.getPackage(); + + const isValid = ( + plugin.isLoaded() && + packageInfos && + packageInfos.get('name') && + packageInfos.get('engines') && + packageInfos.get('engines').get('gitbook') + ); + + if (!isValid) { + return Promise.reject(new Error('Error loading plugin "' + plugin.getName() + '" at "' + plugin.getPath() + '"')); + } + + const engine = packageInfos.get('engines').get('gitbook'); + if (!gitbook.satisfies(engine)) { + return Promise.reject(new Error('GitBook doesn\'t satisfy the requirements of this plugin: ' + engine)); + } + + return Promise(plugin); +} + +module.exports = validatePlugin; diff --git a/packages/gitbook/src/templating/__tests__/conrefsLoader.js b/packages/gitbook/src/templating/__tests__/conrefsLoader.js new file mode 100644 index 0000000..1b8e92f --- /dev/null +++ b/packages/gitbook/src/templating/__tests__/conrefsLoader.js @@ -0,0 +1,111 @@ +const path = require('path'); + +const TemplateEngine = require('../../models/templateEngine'); +const renderTemplate = require('../render'); +const ConrefsLoader = require('../conrefsLoader'); + +describe('ConrefsLoader', () => { + const dirName = __dirname + '/'; + const fileName = path.join(dirName, 'test.md'); + + describe('Git', () => { + let engine; + + before(() => { + engine = new TemplateEngine({ + loader: new ConrefsLoader(dirName) + }); + }); + + it('should include content from git', () => { + return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md" %}') + .then((out) => { + expect(out).toBe('Hello from git'); + }); + }); + + it('should handle deep inclusion (1)', () => { + return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test2.md" %}') + .then((out) => { + expect(out).toBe('First Hello. Hello from git'); + }); + }); + + it('should handle deep inclusion (2)', () => { + return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test3.md" %}') + .then((out) => { + expect(out).toBe('First Hello. Hello from git'); + }); + }); + }); + + describe('Local', () => { + let engine; + + before(() => { + engine = new TemplateEngine({ + loader: new ConrefsLoader(dirName) + }); + }); + + describe('Relative', () => { + it('should resolve basic relative filepath', () => { + return renderTemplate(engine, fileName, '{% include "include.md" %}') + .then((out) => { + expect(out).toBe('Hello World'); + }); + }); + + it('should resolve basic parent filepath', () => { + return renderTemplate(engine, path.join(dirName, 'hello/test.md'), '{% include "../include.md" %}') + .then((out) => { + expect(out).toBe('Hello World'); + }); + }); + }); + + describe('Absolute', function() { + it('should resolve absolute filepath', () => { + return renderTemplate(engine, fileName, '{% include "/include.md" %}') + .then((out) => { + expect(out).toBe('Hello World'); + }); + }); + + it('should resolve absolute filepath when in a directory', () => { + return renderTemplate(engine, path.join(dirName, 'hello/test.md'), '{% include "/include.md" %}') + .then((out) => { + expect(out).toBe('Hello World'); + }); + }); + }); + + }); + + describe('transform', () => { + function transform(filePath, source) { + expect(filePath).toBeA('string'); + expect(source).toBeA('string'); + + expect(filePath).toBe(path.resolve(__dirname, 'include.md')); + expect(source).toBe('Hello World'); + + return 'test-' + source + '-endtest'; + } + + let engine; + + before(() => { + engine = new TemplateEngine({ + loader: new ConrefsLoader(dirName, transform) + }); + }); + + it('should transform included content', () => { + return renderTemplate(engine, fileName, '{% include "include.md" %}') + .then((out) => { + expect(out).toBe('test-Hello World-endtest'); + }); + }); + }); +}); diff --git a/packages/gitbook/src/templating/__tests__/include.md b/packages/gitbook/src/templating/__tests__/include.md new file mode 100644 index 0000000..5e1c309 --- /dev/null +++ b/packages/gitbook/src/templating/__tests__/include.md @@ -0,0 +1 @@ +Hello World
\ No newline at end of file diff --git a/packages/gitbook/src/templating/__tests__/replaceShortcuts.js b/packages/gitbook/src/templating/__tests__/replaceShortcuts.js new file mode 100644 index 0000000..1126f91 --- /dev/null +++ b/packages/gitbook/src/templating/__tests__/replaceShortcuts.js @@ -0,0 +1,31 @@ +const Immutable = require('immutable'); + +const TemplateBlock = require('../../models/templateBlock'); +const replaceShortcuts = require('../replaceShortcuts'); + +describe('replaceShortcuts', function() { + const blocks = Immutable.List([ + TemplateBlock.create('math', { + shortcuts: { + start: '$$', + end: '$$', + parsers: ['markdown'] + } + }) + ]); + + it('should correctly replace inline matches by block', function() { + const content = replaceShortcuts(blocks, 'test.md', 'Hello $$a = b$$'); + expect(content).toBe('Hello {% math %}a = b{% endmath %}'); + }); + + it('should correctly replace multiple inline matches by block', function() { + const content = replaceShortcuts(blocks, 'test.md', 'Hello $$a = b$$ and $$c = d$$'); + expect(content).toBe('Hello {% math %}a = b{% endmath %} and {% math %}c = d{% endmath %}'); + }); + + it('should correctly replace block matches', function() { + const content = replaceShortcuts(blocks, 'test.md', 'Hello\n$$\na = b\n$$\n'); + expect(content).toBe('Hello\n{% math %}\na = b\n{% endmath %}\n'); + }); +}); diff --git a/packages/gitbook/src/templating/conrefsLoader.js b/packages/gitbook/src/templating/conrefsLoader.js new file mode 100644 index 0000000..3660d17 --- /dev/null +++ b/packages/gitbook/src/templating/conrefsLoader.js @@ -0,0 +1,93 @@ +const path = require('path'); +const nunjucks = require('nunjucks'); + +const fs = require('../utils/fs'); +const LocationUtils = require('../utils/location'); +const PathUtils = require('../utils/path'); +const Git = require('../utils/git'); + + +/** + * Template loader resolving both: + * - relative url ("./test.md") + * - absolute url ("/test.md") + * - git url ("") + * + * @param {String} rootFolder + * @param {Function(filePath, source)} transformFn (optional) + * @param {Logger} logger (optional) + */ +const ConrefsLoader = nunjucks.Loader.extend({ + async: true, + + init(rootFolder, transformFn, logger, git = new Git()) { + this.rootFolder = rootFolder; + this.transformFn = transformFn; + this.logger = logger; + this.git = git; + }, + + getSource(sourceURL, callback) { + const that = this; + + this.git.resolve(sourceURL) + .then(function(filepath) { + // Is local file + if (!filepath) { + filepath = path.resolve(sourceURL); + } else { + if (that.logger) that.logger.debug.ln('resolve from git', sourceURL, 'to', filepath); + } + + // Read file from absolute path + return fs.readFile(filepath) + .then(function(source) { + source = source.toString('utf8'); + + if (that.transformFn) { + return that.transformFn(filepath, source); + } + + return source; + }) + .then(function(source) { + return { + src: source, + path: filepath + }; + }); + }) + .nodeify(callback); + }, + + resolve(from, to) { + // If origin is in the book, we enforce result file to be in the book + if (PathUtils.isInRoot(this.rootFolder, from)) { + + // Path of current template in the rootFolder (not absolute to fs) + const fromRelative = path.relative(this.rootFolder, from); + + // Resolve "to" to a filepath relative to rootFolder + const href = LocationUtils.toAbsolute(to, path.dirname(fromRelative), ''); + + // Return absolute path + return PathUtils.resolveInRoot(this.rootFolder, href); + } + + // If origin is in a git repository, we resolve file in the git repository + const gitRoot = this.git.resolveRoot(from); + if (gitRoot) { + return PathUtils.resolveInRoot(gitRoot, to); + } + + // If origin is not in the book (include from a git content ref) + return path.resolve(path.dirname(from), to); + }, + + // Handle all files as relative, so that nunjucks pass responsability to 'resolve' + isRelative(filename) { + return LocationUtils.isRelative(filename); + } +}); + +module.exports = ConrefsLoader; diff --git a/packages/gitbook/src/templating/index.js b/packages/gitbook/src/templating/index.js new file mode 100644 index 0000000..5189eac --- /dev/null +++ b/packages/gitbook/src/templating/index.js @@ -0,0 +1,7 @@ + +module.exports = { + render: require('./render'), + renderFile: require('./renderFile'), + replaceShortcuts: require('./replaceShortcuts'), + ConrefsLoader: require('./conrefsLoader') +}; diff --git a/packages/gitbook/src/templating/listShortcuts.js b/packages/gitbook/src/templating/listShortcuts.js new file mode 100644 index 0000000..099b709 --- /dev/null +++ b/packages/gitbook/src/templating/listShortcuts.js @@ -0,0 +1,31 @@ +const { List } = require('immutable'); +const parsers = require('../parsers'); + +/** + * Return a list of all shortcuts that can apply + * to a file for a TemplatEngine + * + * @param {List<TemplateBlock>} engine + * @param {String} filePath + * @return {List<TemplateShortcut>} shortcuts + */ +function listShortcuts(blocks, filePath) { + const parser = parsers.getForFile(filePath); + + if (!parser) { + return List(); + } + + return blocks + .map(function(block) { + return block.getShortcuts(); + }) + .filter(function(shortcuts) { + return ( + shortcuts && + shortcuts.acceptParser(parser.getName()) + ); + }); +} + +module.exports = listShortcuts; diff --git a/packages/gitbook/src/templating/render.js b/packages/gitbook/src/templating/render.js new file mode 100644 index 0000000..945d6dc --- /dev/null +++ b/packages/gitbook/src/templating/render.js @@ -0,0 +1,40 @@ +const Promise = require('../utils/promise'); +const timing = require('../utils/timing'); +const replaceShortcuts = require('./replaceShortcuts'); + +/** + * Render a template + * + * @param {TemplateEngine} engine + * @param {String} filePath: absolute path for the loader + * @param {String} content + * @param {Object} context (optional) + * @return {Promise<String>} + */ +function renderTemplate(engine, filePath, content, context) { + context = context || {}; + + // Mutable objects to contains all blocks requiring post-processing + const blocks = {}; + + // Create nunjucks environment + const env = engine.toNunjucks(blocks); + + // Replace shortcuts from plugin's blocks + content = replaceShortcuts(engine.getBlocks(), filePath, content); + + return timing.measure( + 'template.render', + + Promise.nfcall( + env.renderString.bind(env), + content, + context, + { + path: filePath + } + ) + ); +} + +module.exports = renderTemplate; diff --git a/packages/gitbook/src/templating/renderFile.js b/packages/gitbook/src/templating/renderFile.js new file mode 100644 index 0000000..a2463f8 --- /dev/null +++ b/packages/gitbook/src/templating/renderFile.js @@ -0,0 +1,41 @@ +const Promise = require('../utils/promise'); +const error = require('../utils/error'); +const render = require('./render'); + +/** + * Render a template + * + * @param {TemplateEngine} engine + * @param {String} filePath + * @param {Object} context + * @return {Promise<TemplateOutput>} + */ +function renderTemplateFile(engine, filePath, context) { + const loader = engine.getLoader(); + + // Resolve the filePath + const resolvedFilePath = loader.resolve(null, filePath); + + return Promise() + .then(function() { + if (!loader.async) { + return loader.getSource(resolvedFilePath); + } + + const deferred = Promise.defer(); + loader.getSource(resolvedFilePath, deferred.makeNodeResolver()); + return deferred.promise; + }) + .then(function(result) { + if (!result) { + throw error.TemplateError(new Error('Not found'), { + filename: filePath + }); + } + + return render(engine, result.path, result.src, context); + }); + +} + +module.exports = renderTemplateFile; diff --git a/packages/gitbook/src/templating/replaceShortcuts.js b/packages/gitbook/src/templating/replaceShortcuts.js new file mode 100644 index 0000000..25f598f --- /dev/null +++ b/packages/gitbook/src/templating/replaceShortcuts.js @@ -0,0 +1,39 @@ +const escapeStringRegexp = require('escape-string-regexp'); +const listShortcuts = require('./listShortcuts'); + +/** + * Apply a shortcut of block to a template + * @param {String} content + * @param {Shortcut} shortcut + * @return {String} + */ +function applyShortcut(content, shortcut) { + const start = shortcut.getStart(); + const end = shortcut.getEnd(); + + const tagStart = shortcut.getStartTag(); + const tagEnd = shortcut.getEndTag(); + + const regex = new RegExp( + escapeStringRegexp(start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(end), + 'g' + ); + return content.replace(regex, function(all, match) { + return '{% ' + tagStart + ' %}' + match + '{% ' + tagEnd + ' %}'; + }); +} + +/** + * Replace shortcuts from blocks in a string + * + * @param {List<TemplateBlock>} engine + * @param {String} filePath + * @param {String} content + * @return {String} + */ +function replaceShortcuts(blocks, filePath, content) { + const shortcuts = listShortcuts(blocks, filePath); + return shortcuts.reduce(applyShortcut, content); +} + +module.exports = replaceShortcuts; diff --git a/packages/gitbook/src/utils/__tests__/git.js b/packages/gitbook/src/utils/__tests__/git.js new file mode 100644 index 0000000..29be4a1 --- /dev/null +++ b/packages/gitbook/src/utils/__tests__/git.js @@ -0,0 +1,55 @@ +const path = require('path'); +const Git = require('../git'); + +describe('Git', () => { + + describe('URL parsing', () => { + + it('should correctly validate git urls', () => { + // HTTPS + expect(Git.isUrl('git+https://github.com/Hello/world.git')).toBeTruthy(); + + // SSH + expect(Git.isUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1')).toBeTruthy(); + + // Non valid + expect(Git.isUrl('https://github.com/Hello/world.git')).toBeFalsy(); + expect(Git.isUrl('README.md')).toBeFalsy(); + }); + + it('should parse HTTPS urls', () => { + const parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md'); + + expect(parts.host).toBe('https://gist.github.com/69ea4542e4c8967d2fa7.git'); + expect(parts.ref).toBe(null); + expect(parts.filepath).toBe('test.md'); + }); + + it('should parse HTTPS urls with a reference', () => { + const parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md#1.0.0'); + + expect(parts.host).toBe('https://gist.github.com/69ea4542e4c8967d2fa7.git'); + expect(parts.ref).toBe('1.0.0'); + expect(parts.filepath).toBe('test.md'); + }); + + it('should parse SSH urls', () => { + const parts = Git.parseUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1'); + + expect(parts.host).toBe('git@github.com:GitbookIO/gitbook.git'); + expect(parts.ref).toBe('e1594cde2c32e4ff48f6c4eff3d3d461743d74e1'); + expect(parts.filepath).toBe('directory/README.md'); + }); + }); + + describe('Cloning and resolving', () => { + it('should clone an HTTPS url', () => { + const git = new Git(); + return git.resolve('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md') + .then(function(filename) { + expect(path.extname(filename)).toBe('.md'); + }); + }); + }); + +}); diff --git a/packages/gitbook/src/utils/__tests__/location.js b/packages/gitbook/src/utils/__tests__/location.js new file mode 100644 index 0000000..a565adb --- /dev/null +++ b/packages/gitbook/src/utils/__tests__/location.js @@ -0,0 +1,99 @@ +const LocationUtils = require('../location'); + +describe('LocationUtils', function() { + it('should correctly test external location', function() { + expect(LocationUtils.isExternal('http://google.fr')).toBe(true); + expect(LocationUtils.isExternal('https://google.fr')).toBe(true); + expect(LocationUtils.isExternal('test.md')).toBe(false); + expect(LocationUtils.isExternal('folder/test.md')).toBe(false); + expect(LocationUtils.isExternal('/folder/test.md')).toBe(false); + expect(LocationUtils.isExternal('data:image/png')).toBe(false); + }); + + it('should correctly test data:uri location', function() { + expect(LocationUtils.isDataURI('data:image/png')).toBe(true); + expect(LocationUtils.isDataURI('http://google.fr')).toBe(false); + expect(LocationUtils.isDataURI('https://google.fr')).toBe(false); + expect(LocationUtils.isDataURI('test.md')).toBe(false); + expect(LocationUtils.isDataURI('data.md')).toBe(false); + }); + + it('should correctly detect anchor location', function() { + expect(LocationUtils.isAnchor('#test')).toBe(true); + expect(LocationUtils.isAnchor(' #test')).toBe(true); + expect(LocationUtils.isAnchor('https://google.fr#test')).toBe(false); + expect(LocationUtils.isAnchor('test.md#test')).toBe(false); + }); + + describe('.relative', function() { + it('should resolve to a relative path (same folder)', function() { + expect(LocationUtils.relative('links/', 'links/test.md')).toBe('test.md'); + }); + + it('should resolve to a relative path (parent folder)', function() { + expect(LocationUtils.relative('links/', 'test.md')).toBe('../test.md'); + }); + + it('should resolve to a relative path (child folder)', function() { + expect(LocationUtils.relative('links/', 'links/hello/test.md')).toBe('hello/test.md'); + }); + }); + + describe('.flatten', function() { + it('should remove leading slash', function() { + expect(LocationUtils.flatten('/test.md')).toBe('test.md'); + expect(LocationUtils.flatten('/hello/cool.md')).toBe('hello/cool.md'); + }); + + it('should remove leading slashes', function() { + expect(LocationUtils.flatten('///test.md')).toBe('test.md'); + }); + + it('should not break paths', function() { + expect(LocationUtils.flatten('hello/cool.md')).toBe('hello/cool.md'); + }); + }); + + describe('.toAbsolute', function() { + it('should correctly transform as absolute', function() { + expect(LocationUtils.toAbsolute('http://google.fr')).toBe('http://google.fr'); + expect(LocationUtils.toAbsolute('test.md', './', './')).toBe('test.md'); + expect(LocationUtils.toAbsolute('folder/test.md', './', './')).toBe('folder/test.md'); + }); + + it('should correctly handle windows path', function() { + expect(LocationUtils.toAbsolute('folder\\test.md', './', './')).toBe('folder/test.md'); + }); + + it('should correctly handle absolute path', function() { + expect(LocationUtils.toAbsolute('/test.md', './', './')).toBe('test.md'); + expect(LocationUtils.toAbsolute('/test.md', 'test', 'test')).toBe('../test.md'); + expect(LocationUtils.toAbsolute('/sub/test.md', 'test', 'test')).toBe('../sub/test.md'); + expect(LocationUtils.toAbsolute('/test.png', 'folder', '')).toBe('test.png'); + }); + + it('should correctly handle absolute path (windows)', function() { + expect(LocationUtils.toAbsolute('\\test.png', 'folder', '')).toBe('test.png'); + }); + + it('should resolve path starting by "/" in root directory', function() { + expect( + LocationUtils.toAbsolute('/test/hello.md', './', './') + ).toBe('test/hello.md'); + }); + + it('should resolve path starting by "/" in child directory', function() { + expect( + LocationUtils.toAbsolute('/test/hello.md', './hello', './') + ).toBe('test/hello.md'); + }); + + it('should resolve path starting by "/" in child directory, with same output directory', function() { + expect( + LocationUtils.toAbsolute('/test/hello.md', './hello', './hello') + ).toBe('../test/hello.md'); + }); + }); + +}); + diff --git a/packages/gitbook/src/utils/__tests__/path.js b/packages/gitbook/src/utils/__tests__/path.js new file mode 100644 index 0000000..1f8a1d3 --- /dev/null +++ b/packages/gitbook/src/utils/__tests__/path.js @@ -0,0 +1,17 @@ +const path = require('path'); + +describe('Paths', function() { + const PathUtils = require('..//path'); + + describe('setExtension', function() { + it('should correctly change extension of filename', function() { + expect(PathUtils.setExtension('test.md', '.html')).toBe('test.html'); + expect(PathUtils.setExtension('test.md', '.json')).toBe('test.json'); + }); + + it('should correctly change extension of path', function() { + expect(PathUtils.setExtension('hello/test.md', '.html')).toBe(path.normalize('hello/test.html')); + expect(PathUtils.setExtension('hello/test.md', '.json')).toBe(path.normalize('hello/test.json')); + }); + }); +}); diff --git a/packages/gitbook/src/utils/command.js b/packages/gitbook/src/utils/command.js new file mode 100644 index 0000000..5533ca8 --- /dev/null +++ b/packages/gitbook/src/utils/command.js @@ -0,0 +1,118 @@ +const is = require('is'); +const childProcess = require('child_process'); +const spawn = require('spawn-cmd').spawn; +const Promise = require('./promise'); + +/** + * Execute a command + * + * @param {String} command + * @param {Object} options + * @return {Promise} + */ +function exec(command, options) { + const d = Promise.defer(); + + const child = childProcess.exec(command, options, function(err, stdout, stderr) { + if (!err) { + return d.resolve(); + } + + err.message = stdout.toString('utf8') + stderr.toString('utf8'); + d.reject(err); + }); + + child.stdout.on('data', function(data) { + d.notify(data); + }); + + child.stderr.on('data', function(data) { + d.notify(data); + }); + + return d.promise; +} + +/** + * Spawn an executable + * + * @param {String} command + * @param {Array} args + * @param {Object} options + * @return {Promise} + */ +function spawnCmd(command, args, options) { + const d = Promise.defer(); + const child = spawn(command, args, options); + + child.on('error', function(error) { + return d.reject(error); + }); + + child.stdout.on('data', function(data) { + d.notify(data); + }); + + child.stderr.on('data', function(data) { + d.notify(data); + }); + + child.on('close', function(code) { + if (code === 0) { + d.resolve(); + } else { + d.reject(new Error('Error with command "' + command + '"')); + } + }); + + return d.promise; +} + +/** + * Transform an option object to a command line string + * + * @param {String|number} value + * @param {String} + */ +function escapeShellArg(value) { + if (is.number(value)) { + return value; + } + + value = String(value); + value = value.replace(/"/g, '\\"'); + + return '"' + value + '"'; +} + +/** + * Transform a map of options into a command line arguments string + * + * @param {Object} options + * @return {String} + */ +function optionsToShellArgs(options) { + const result = []; + + for (const key in options) { + const value = options[key]; + + if (value === null || value === undefined || value === false) { + continue; + } + + if (is.bool(value)) { + result.push(key); + } else { + result.push(key + '=' + escapeShellArg(value)); + } + } + + return result.join(' '); +} + +module.exports = { + exec, + spawn: spawnCmd, + optionsToShellArgs +}; diff --git a/packages/gitbook/src/utils/error.js b/packages/gitbook/src/utils/error.js new file mode 100644 index 0000000..925b5ff --- /dev/null +++ b/packages/gitbook/src/utils/error.js @@ -0,0 +1,99 @@ +const is = require('is'); + +const TypedError = require('error/typed'); +const WrappedError = require('error/wrapped'); + + +// Enforce as an Error object, and cleanup message +function enforce(err) { + if (is.string(err)) err = new Error(err); + err.message = err.message.replace(/^Error: /, ''); + + return err; +} + +// Random error wrappers during parsing/generation +const ParsingError = WrappedError({ + message: 'Parsing Error: {origMessage}', + type: 'parse' +}); +const OutputError = WrappedError({ + message: 'Output Error: {origMessage}', + type: 'generate' +}); + +// A file does not exists +const FileNotFoundError = TypedError({ + type: 'file.not-found', + message: 'No "{filename}" file (or is ignored)', + filename: null +}); + +// A file cannot be parsed +const FileNotParsableError = TypedError({ + type: 'file.not-parsable', + message: '"{filename}" file cannot be parsed', + filename: null +}); + +// A file is outside the scope +const FileOutOfScopeError = TypedError({ + type: 'file.out-of-scope', + message: '"{filename}" not in "{root}"', + filename: null, + root: null, + code: 'EACCESS' +}); + +// A file is outside the scope +const RequireInstallError = TypedError({ + type: 'install.required', + message: '"{cmd}" is not installed.\n{install}', + cmd: null, + code: 'ENOENT', + install: '' +}); + +// Error for nunjucks templates +const TemplateError = WrappedError({ + message: 'Error compiling template "{filename}": {origMessage}', + type: 'template', + filename: null +}); + +// Error for nunjucks templates +const PluginError = WrappedError({ + message: 'Error with plugin "{plugin}": {origMessage}', + type: 'plugin', + plugin: null +}); + +// Error with the book's configuration +const ConfigurationError = WrappedError({ + message: 'Error with book\'s configuration: {origMessage}', + type: 'configuration' +}); + +// Error during ebook generation +const EbookError = WrappedError({ + message: 'Error during ebook generation: {origMessage}\n{stdout}', + type: 'ebook', + stdout: '' +}); + +module.exports = { + enforce, + + ParsingError, + OutputError, + RequireInstallError, + + FileNotParsableError, + FileNotFoundError, + FileOutOfScopeError, + + TemplateError, + PluginError, + ConfigurationError, + EbookError +}; diff --git a/packages/gitbook/src/utils/fs.js b/packages/gitbook/src/utils/fs.js new file mode 100644 index 0000000..17b2ebb --- /dev/null +++ b/packages/gitbook/src/utils/fs.js @@ -0,0 +1,170 @@ +const fs = require('graceful-fs'); +const mkdirp = require('mkdirp'); +const destroy = require('destroy'); +const rmdir = require('rmdir'); +const tmp = require('tmp'); +const request = require('request'); +const path = require('path'); +const cp = require('cp'); +const cpr = require('cpr'); + +const Promise = require('./promise'); + +// Write a stream to a file +function writeStream(filename, st) { + const d = Promise.defer(); + + const wstream = fs.createWriteStream(filename); + const cleanup = function() { + destroy(wstream); + wstream.removeAllListeners(); + }; + + wstream.on('finish', function() { + cleanup(); + d.resolve(); + }); + wstream.on('error', function(err) { + cleanup(); + d.reject(err); + }); + + st.on('error', function(err) { + cleanup(); + d.reject(err); + }); + + st.pipe(wstream); + + return d.promise; +} + +// Return a promise resolved with a boolean +function fileExists(filename) { + const d = Promise.defer(); + + fs.exists(filename, function(exists) { + d.resolve(exists); + }); + + return d.promise; +} + +// Generate temporary file +function genTmpFile(opts) { + return Promise.nfcall(tmp.file, opts) + .get(0); +} + +// Generate temporary dir +function genTmpDir(opts) { + return Promise.nfcall(tmp.dir, opts) + .get(0); +} + +// Download an image +function download(uri, dest) { + return writeStream(dest, request(uri)); +} + +// Find a filename available in a folder +function uniqueFilename(base, filename) { + const ext = path.extname(filename); + filename = path.resolve(base, filename); + filename = path.join(path.dirname(filename), path.basename(filename, ext)); + + let _filename = filename + ext; + + let i = 0; + while (fs.existsSync(filename)) { + _filename = filename + '_' + i + ext; + i = i + 1; + } + + return Promise(path.relative(base, _filename)); +} + +// Create all required folder to create a file +function ensureFile(filename) { + const base = path.dirname(filename); + return Promise.nfcall(mkdirp, base); +} + +// Remove a folder +function rmDir(base) { + return Promise.nfcall(rmdir, base, { + fs + }); +} + +/** + Assert a file, if it doesn't exist, call "generator" + + @param {String} filePath + @param {Function} generator + @return {Promise} +*/ +function assertFile(filePath, generator) { + return fileExists(filePath) + .then(function(exists) { + if (exists) return; + + return generator(); + }); +} + +/** + Pick a file, returns the absolute path if exists, undefined otherwise + + @param {String} rootFolder + @param {String} fileName + @return {String} +*/ +function pickFile(rootFolder, fileName) { + const result = path.join(rootFolder, fileName); + if (fs.existsSync(result)) { + return result; + } + + return undefined; +} + +/** + Ensure that a directory exists and is empty + + @param {String} folder + @return {Promise} +*/ +function ensureFolder(rootFolder) { + return rmDir(rootFolder) + .fail(function() { + return Promise(); + }) + .then(function() { + return Promise.nfcall(mkdirp, rootFolder); + }); +} + +module.exports = { + exists: fileExists, + existsSync: fs.existsSync, + mkdirp: Promise.nfbind(mkdirp), + readFile: Promise.nfbind(fs.readFile), + writeFile: Promise.nfbind(fs.writeFile), + assertFile, + pickFile, + stat: Promise.nfbind(fs.stat), + statSync: fs.statSync, + readdir: Promise.nfbind(fs.readdir), + writeStream, + readStream: fs.createReadStream, + copy: Promise.nfbind(cp), + copyDir: Promise.nfbind(cpr), + tmpFile: genTmpFile, + tmpDir: genTmpDir, + download, + uniqueFilename, + ensureFile, + ensureFolder, + rmDir +}; diff --git a/packages/gitbook/src/utils/genKey.js b/packages/gitbook/src/utils/genKey.js new file mode 100644 index 0000000..e4982f4 --- /dev/null +++ b/packages/gitbook/src/utils/genKey.js @@ -0,0 +1,13 @@ +let lastKey = 0; + +/* + Generate a random key + @return {String} +*/ +function generateKey() { + lastKey += 1; + const str = lastKey.toString(16); + return '00000'.slice(str.length) + str; +} + +module.exports = generateKey; diff --git a/packages/gitbook/src/utils/git.js b/packages/gitbook/src/utils/git.js new file mode 100644 index 0000000..2b2a3e3 --- /dev/null +++ b/packages/gitbook/src/utils/git.js @@ -0,0 +1,158 @@ +const is = require('is'); +const path = require('path'); +const crc = require('crc'); +const URI = require('urijs'); + +const pathUtil = require('./path'); +const Promise = require('./promise'); +const command = require('./command'); +const fs = require('./fs'); + +const GIT_PREFIX = 'git+'; + +class Git { + constructor() { + this.tmpDir = null; + this.cloned = {}; + } + + // Return an unique ID for a combinaison host/ref + repoID(host, ref) { + return crc.crc32(host + '#' + (ref || '')).toString(16); + } + + // Allocate a temporary folder for cloning repos in it + allocateDir() { + const that = this; + + if (this.tmpDir) { + return Promise(); + } + + return fs.tmpDir() + .then(function(dir) { + that.tmpDir = dir; + }); + } + + /** + * Clone a git repository if non existant + * @param {String} host: url of the git repository + * @param {String} ref: branch/commit/tag to checkout + * @return {Promise<String>} repoPath + */ + clone(host, ref) { + const that = this; + + return this.allocateDir() + + // Return or clone the git repo + .then(function() { + // Unique ID for repo/ref combinaison + const repoId = that.repoID(host, ref); + + // Absolute path to the folder + const repoPath = path.join(that.tmpDir, repoId); + + if (that.cloned[repoId]) return repoPath; + + // Clone repo + return command.exec('git clone ' + host + ' ' + repoPath) + + // Checkout reference if specified + .then(function() { + that.cloned[repoId] = true; + + if (!ref) return; + return command.exec('git checkout ' + ref, { cwd: repoPath }); + }) + .thenResolve(repoPath); + }); + } + + /** + * Resole a git url, clone the repo and return the path to the right file. + * @param {String} giturl + * @return {Promise<String>} filePath + */ + resolve(giturl) { + // Path to a file in a git repo? + if (!Git.isUrl(giturl)) { + if (this.resolveRoot(giturl)) return Promise(giturl); + return Promise(null); + } + if (is.string(giturl)) giturl = Git.parseUrl(giturl); + if (!giturl) return Promise(null); + + // Clone or get from cache + return this.clone(giturl.host, giturl.ref) + .then(function(repo) { + return path.resolve(repo, giturl.filepath); + }); + } + + /** + * Return root of git repo from a filepath + * @param {String} filePath + * @return {String} repoPath + */ + resolveRoot(filepath) { + // No git repo cloned, or file is not in a git repository + if (!this.tmpDir || !pathUtil.isInRoot(this.tmpDir, filepath)) return null; + + // Extract first directory (is the repo id) + const relativeToGit = path.relative(this.tmpDir, filepath); + const repoId = relativeToGit.split(path.sep)[0]; + + if (!repoId) { + return; + } + + // Return an absolute file + return path.resolve(this.tmpDir, repoId); + } + + /** + * Check if an url is a git dependency url + * @param {String} giturl + * @return {Boolean} isUrl + */ + static isUrl(giturl) { + return (giturl.indexOf(GIT_PREFIX) === 0); + } + + /** + * Parse and extract infos + * @param {String} giturl + * @return {Object} { host, ref, filepath } + */ + static parseUrl(giturl) { + if (!Git.isUrl(giturl)) { + return null; + } + giturl = giturl.slice(GIT_PREFIX.length); + + const uri = new URI(giturl); + const ref = uri.fragment() || null; + uri.fragment(null); + + // Extract file inside the repo (after the .git) + const fileParts = uri.path().split('.git'); + let filepath = fileParts.length > 1 ? fileParts.slice(1).join('.git') : ''; + if (filepath[0] == '/') { + filepath = filepath.slice(1); + } + + // Recreate pathname without the real filename + uri.path(fileParts[0] + '.git'); + + return { + host: uri.toString(), + ref, + filepath + }; + } + +} + +module.exports = Git; diff --git a/packages/gitbook/src/utils/images.js b/packages/gitbook/src/utils/images.js new file mode 100644 index 0000000..808be63 --- /dev/null +++ b/packages/gitbook/src/utils/images.js @@ -0,0 +1,60 @@ +const Promise = require('./promise'); +const command = require('./command'); +const fs = require('./fs'); +const error = require('./error'); + +// Convert a svg file to a pmg +function convertSVGToPNG(source, dest, options) { + if (!fs.existsSync(source)) return Promise.reject(new error.FileNotFoundError({ filename: source })); + + return command.spawn('svgexport', [source, dest]) + .fail(function(err) { + if (err.code == 'ENOENT') { + err = error.RequireInstallError({ + cmd: 'svgexport', + install: 'Install it using: "npm install svgexport -g"' + }); + } + throw err; + }) + .then(function() { + if (fs.existsSync(dest)) return; + + throw new Error('Error converting ' + source + ' into ' + dest); + }); +} + +// Convert a svg buffer to a png file +function convertSVGBufferToPNG(buf, dest) { + // Create a temporary SVG file to convert + return fs.tmpFile({ + postfix: '.svg' + }) + .then(function(tmpSvg) { + return fs.writeFile(tmpSvg, buf) + .then(function() { + return convertSVGToPNG(tmpSvg, dest); + }); + }); +} + +// Converts a inline data: to png file +function convertInlinePNG(source, dest) { + if (!/^data\:image\/png/.test(source)) return Promise.reject(new Error('Source is not a PNG data-uri')); + + const base64data = source.split('data:image/png;base64,')[1]; + const buf = new Buffer(base64data, 'base64'); + + return fs.writeFile(dest, buf) + .then(function() { + if (fs.existsSync(dest)) return; + + throw new Error('Error converting ' + source + ' into ' + dest); + }); +} + +module.exports = { + convertSVGToPNG, + convertSVGBufferToPNG, + convertInlinePNG +}; diff --git a/packages/gitbook/src/utils/location.js b/packages/gitbook/src/utils/location.js new file mode 100644 index 0000000..6dc41ba --- /dev/null +++ b/packages/gitbook/src/utils/location.js @@ -0,0 +1,139 @@ +const url = require('url'); +const path = require('path'); + +// Is the url an external url +function isExternal(href) { + try { + return Boolean(url.parse(href).protocol) && !isDataURI(href); + } catch (err) { + return false; + } +} + +// Is the url an iniline data-uri +function isDataURI(href) { + try { + return Boolean(url.parse(href).protocol) && (url.parse(href).protocol === 'data:'); + } catch (err) { + return false; + } +} + +// Inverse of isExternal +function isRelative(href) { + return !isExternal(href); +} + +// Return true if the link is an achor +function isAnchor(href) { + try { + const parsed = url.parse(href); + return !!(!parsed.protocol && !parsed.path && parsed.hash); + } catch (err) { + return false; + } +} + +// Normalize a path to be a link +function normalize(s) { + return path.normalize(s).replace(/\\/g, '/'); +} + +/** + * Flatten a path, it removes the leading "/" + * + * @param {String} href + * @return {String} + */ +function flatten(href) { + href = normalize(href); + if (href[0] == '/') { + href = normalize(href.slice(1)); + } + + return href; +} + +/** + * Convert a relative path to absolute + * + * @param {String} href + * @param {String} dir: directory parent of the file currently in rendering process + * @param {String} outdir: directory parent from the html output + * @return {String} + */ +function toAbsolute(_href, dir, outdir) { + if (isExternal(_href) || isDataURI(_href)) { + return _href; + } + + outdir = outdir == undefined ? dir : outdir; + + _href = normalize(_href); + dir = normalize(dir); + outdir = normalize(outdir); + + // Path "_href" inside the base folder + let hrefInRoot = normalize(path.join(dir, _href)); + if (_href[0] == '/') { + hrefInRoot = normalize(_href.slice(1)); + } + + // Make it relative to output + _href = path.relative(outdir, hrefInRoot); + + // Normalize windows paths + _href = normalize(_href); + + return _href; +} + +/** + * Convert an absolute path to a relative path for a specific folder (dir) + * ('test/', 'hello.md') -> '../hello.md' + * + * @param {String} dir: current directory + * @param {String} file: absolute path of file + * @return {String} + */ +function relative(dir, file) { + const isDirectory = file.slice(-1) === '/'; + return normalize(path.relative(dir, file)) + (isDirectory ? '/' : ''); +} + +/** + * Convert an absolute path to a relative path for a specific folder (dir) + * ('test/test.md', 'hello.md') -> '../hello.md' + * + * @param {String} baseFile: current file + * @param {String} file: absolute path of file + * @return {String} + */ +function relativeForFile(baseFile, file) { + return relative(path.dirname(baseFile), file); +} + +/** + * Compare two paths, return true if they are identical + * ('README.md', './README.md') -> true + * + * @param {String} p1: first path + * @param {String} p2: second path + * @return {Boolean} + */ +function areIdenticalPaths(p1, p2) { + return normalize(p1) === normalize(p2); +} + +module.exports = { + areIdenticalPaths, + isDataURI, + isExternal, + isRelative, + isAnchor, + normalize, + toAbsolute, + relative, + relativeForFile, + flatten +}; diff --git a/packages/gitbook/src/utils/logger.js b/packages/gitbook/src/utils/logger.js new file mode 100644 index 0000000..25f8517 --- /dev/null +++ b/packages/gitbook/src/utils/logger.js @@ -0,0 +1,170 @@ +const is = require('is'); +const util = require('util'); +const color = require('bash-color'); +const Immutable = require('immutable'); + +const LEVELS = Immutable.Map({ + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + DISABLED: 10 +}); + +const COLORS = Immutable.Map({ + DEBUG: color.purple, + INFO: color.cyan, + WARN: color.yellow, + ERROR: color.red +}); + +function Logger(write, logLevel) { + if (!(this instanceof Logger)) return new Logger(write, logLevel); + + this._write = write || function(msg) { + if (process.stdout) { + process.stdout.write(msg); + } + }; + this.lastChar = '\n'; + + this.setLevel(logLevel || 'info'); + + // Create easy-to-use method like "logger.debug.ln('....')" + LEVELS.forEach(function(level, levelKey) { + if (levelKey === 'DISABLED') { + return; + } + levelKey = levelKey.toLowerCase(); + + this[levelKey] = this.log.bind(this, level); + this[levelKey].ln = this.logLn.bind(this, level); + this[levelKey].ok = this.ok.bind(this, level); + this[levelKey].fail = this.fail.bind(this, level); + this[levelKey].promise = this.promise.bind(this, level); + }, this); +} + +/** + Change minimum level + + @param {String} logLevel +*/ +Logger.prototype.setLevel = function(logLevel) { + if (is.string(logLevel)) { + logLevel = logLevel.toUpperCase(); + logLevel = LEVELS.get(logLevel); + } + + this.logLevel = logLevel; +}; + +/** + Return minimum logging level + + @return {Number} +*/ +Logger.prototype.getLevel = function(logLevel) { + return this.logLevel; +}; + +/** + Print a simple string + + @param {String} +*/ +Logger.prototype.write = function(msg) { + msg = msg.toString(); + this.lastChar = msg[msg.length - 1]; + return this._write(msg); +}; + +/** + Format a string using the first argument as a printf-like format. +*/ +Logger.prototype.format = function(...args) { + return util.format(...args); +}; + +/** + Print a line + + @param {String} +*/ +Logger.prototype.writeLn = function(msg) { + return this.write((msg || '') + '\n'); +}; + +/** + Log/Print a message if level is allowed + + @param {Number} level +*/ +Logger.prototype.log = function(level, ...args) { + if (level < this.logLevel) return; + + const levelKey = LEVELS.findKey(function(v) { + return v === level; + }); + let msg = this.format(...args); + + if (this.lastChar == '\n') { + msg = COLORS.get(levelKey)(levelKey.toLowerCase() + ':') + ' ' + msg; + } + + return this.write(msg); +}; + +/** + Log/Print a line if level is allowed +*/ +Logger.prototype.logLn = function(...args) { + if (this.lastChar != '\n') this.write('\n'); + + args.push('\n'); + return this.log(...args); +}; + +/** + Log a confirmation [OK] +*/ +Logger.prototype.ok = function(level, ...args) { + const msg = this.format(...args); + + if (args.length > 0) { + this.logLn(level, color.green('>> ') + msg.trim().replace(/\n/g, color.green('\n>> '))); + } else { + this.log(level, color.green('OK'), '\n'); + } +}; + +/** + Log a "FAIL" +*/ +Logger.prototype.fail = function(level) { + return this.log(level, color.red('ERROR') + '\n'); +}; + +/** + Log state of a promise + + @param {Number} level + @param {Promise} + @return {Promise} +*/ +Logger.prototype.promise = function(level, p) { + const that = this; + + return p + .then(function(st) { + that.ok(level); + return st; + }, function(err) { + that.fail(level); + throw err; + }); +}; + +Logger.LEVELS = LEVELS; + +module.exports = Logger; diff --git a/packages/gitbook/src/utils/mergeDefaults.js b/packages/gitbook/src/utils/mergeDefaults.js new file mode 100644 index 0000000..b2e8c3d --- /dev/null +++ b/packages/gitbook/src/utils/mergeDefaults.js @@ -0,0 +1,16 @@ +const Immutable = require('immutable'); + +/** + * Merge + * @param {Object|Map} obj + * @param {Object|Map} src + * @return {Object} + */ +function mergeDefaults(obj, src) { + const objValue = Immutable.fromJS(obj); + const srcValue = Immutable.fromJS(src); + + return srcValue.mergeDeep(objValue).toJS(); +} + +module.exports = mergeDefaults; diff --git a/packages/gitbook/src/utils/path.js b/packages/gitbook/src/utils/path.js new file mode 100644 index 0000000..01c2cbf --- /dev/null +++ b/packages/gitbook/src/utils/path.js @@ -0,0 +1,71 @@ +const path = require('path'); +const error = require('./error'); + +// Normalize a filename +function normalizePath(filename) { + return path.normalize(filename); +} + +// Return true if file path is inside a folder +function isInRoot(root, filename) { + root = path.normalize(root); + filename = path.normalize(filename); + + if (root === '.') { + return true; + } + if (root[root.length - 1] != path.sep) { + root = root + path.sep; + } + + return (filename.substr(0, root.length) === root); +} + +// Resolve paths in a specific folder +// Throw error if file is outside this folder +function resolveInRoot(root, ...args) { + const input = args + .reduce(function(current, p) { + // Handle path relative to book root ("/README.md") + if (p[0] == '/' || p[0] == '\\') return p.slice(1); + + return current ? path.join(current, p) : path.normalize(p); + }, ''); + + const result = path.resolve(root, input); + + if (!isInRoot(root, result)) { + throw new error.FileOutOfScopeError({ + filename: result, + root + }); + } + + return result; +} + +// Chnage extension of a file +function setExtension(filename, ext) { + return path.join( + path.dirname(filename), + path.basename(filename, path.extname(filename)) + ext + ); +} + +/* + Return true if a filename is relative. + + @param {String} + @return {Boolean} +*/ +function isPureRelative(filename) { + return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0); +} + +module.exports = { + isInRoot, + resolveInRoot, + normalize: normalizePath, + setExtension, + isPureRelative +}; diff --git a/packages/gitbook/src/utils/promise.js b/packages/gitbook/src/utils/promise.js new file mode 100644 index 0000000..8cbbd47 --- /dev/null +++ b/packages/gitbook/src/utils/promise.js @@ -0,0 +1,146 @@ +const Q = require('q'); +const Immutable = require('immutable'); + +// Debugging for long stack traces +if (process.env.DEBUG || process.env.CI) { + Q.longStackSupport = true; +} + +/** + * Reduce an array to a promise + * + * @param {Array|List} arr + * @param {Function(value, element, index)} + * @return {Promise<Mixed>} + */ +function reduce(arr, iter, base) { + arr = Immutable.Iterable.isIterable(arr) ? arr : Immutable.List(arr); + + return arr.reduce(function(prev, elem, key) { + return prev + .then(function(val) { + return iter(val, elem, key); + }); + }, Q(base)); +} + +/** + * Iterate over an array using an async iter + * + * @param {Array|List} arr + * @param {Function(value, element, index)} + * @return {Promise} + */ +function forEach(arr, iter) { + return reduce(arr, function(val, el, key) { + return iter(el, key); + }); +} + +/** + * Transform an array + * + * @param {Array|List} arr + * @param {Function(value, element, index)} + * @return {Promise} + */ +function serie(arr, iter, base) { + return reduce(arr, function(before, item, key) { + return Q(iter(item, key)) + .then(function(r) { + before.push(r); + return before; + }); + }, []); +} + +/** + * Iter over an array and return first result (not null) + * + * @param {Array|List} arr + * @param {Function(element, index)} + * @return {Promise<Mixed>} + */ +function some(arr, iter) { + arr = Immutable.List(arr); + + return arr.reduce(function(prev, elem, i) { + return prev.then(function(val) { + if (val) return val; + + return iter(elem, i); + }); + }, Q()); +} + +/** + * Map an array using an async (promised) iterator + * + * @param {Array|List} arr + * @param {Function(element, index)} + * @return {Promise<List>} + */ +function mapAsList(arr, iter) { + return reduce(arr, function(prev, entry, i) { + return Q(iter(entry, i)) + .then(function(out) { + prev.push(out); + return prev; + }); + }, []); +} + +/** + * Map an array or map + * + * @param {Array|List|Map|OrderedMap} arr + * @param {Function(element, key)} + * @return {Promise<List|Map|OrderedMap>} + */ +function map(arr, iter) { + if (Immutable.Map.isMap(arr)) { + let type = 'Map'; + if (Immutable.OrderedMap.isOrderedMap(arr)) { + type = 'OrderedMap'; + } + + return mapAsList(arr, function(value, key) { + return Q(iter(value, key)) + .then(function(result) { + return [key, result]; + }); + }) + .then(function(result) { + return Immutable[type](result); + }); + } else { + return mapAsList(arr, iter) + .then(function(result) { + return Immutable.List(result); + }); + } +} + + +/** + * Wrap a function in a promise + * + * @param {Function} func + * @return {Funciton} + */ +function wrap(func) { + return function(...args) { + return Q() + .then(function() { + return func(...args); + }); + }; +} + +module.exports = Q; +module.exports.forEach = forEach; +module.exports.reduce = reduce; +module.exports.map = map; +module.exports.serie = serie; +module.exports.some = some; +module.exports.wrapfn = wrap; diff --git a/packages/gitbook/src/utils/reducedObject.js b/packages/gitbook/src/utils/reducedObject.js new file mode 100644 index 0000000..196a72c --- /dev/null +++ b/packages/gitbook/src/utils/reducedObject.js @@ -0,0 +1,33 @@ +const Immutable = require('immutable'); + +/** + * Reduce the difference between a map and its default version + * @param {Map} defaultVersion + * @param {Map} currentVersion + * @return {Map} The properties of currentVersion that differs from defaultVersion + */ +function reducedObject(defaultVersion, currentVersion) { + if (defaultVersion === undefined) { + return currentVersion; + } + + return currentVersion.reduce(function(result, value, key) { + const defaultValue = defaultVersion.get(key); + + if (Immutable.Map.isMap(value)) { + const diffs = reducedObject(defaultValue, value); + + if (diffs.size > 0) { + return result.set(key, diffs); + } + } + + if (Immutable.is(defaultValue, value)) { + return result; + } + + return result.set(key, value); + }, Immutable.Map()); +} + +module.exports = reducedObject; diff --git a/packages/gitbook/src/utils/timing.js b/packages/gitbook/src/utils/timing.js new file mode 100644 index 0000000..38ffd00 --- /dev/null +++ b/packages/gitbook/src/utils/timing.js @@ -0,0 +1,104 @@ +const Immutable = require('immutable'); +const is = require('is'); +const Promise = require('./promise'); + +const timers = {}; +const startDate = Date.now(); + +/** + * Mesure an operation + * + * @param {String} type + * @param {Promise|Function} p + * @return {Promise|Mixed} result + */ +function measure(type, p) { + timers[type] = timers[type] || { + type, + count: 0, + total: 0, + min: undefined, + max: 0 + }; + + const start = Date.now(); + + const after = () => { + const end = Date.now(); + const duration = (end - start); + + timers[type].count ++; + timers[type].total += duration; + + if (is.undefined(timers[type].min)) { + timers[type].min = duration; + } else { + timers[type].min = Math.min(timers[type].min, duration); + } + + timers[type].max = Math.max(timers[type].max, duration); + }; + + if (Promise.isPromise(p)) { + return p.fin(after); + } + + const result = p(); + after(); + + return result; +} + +/** + * Return a milliseconds number as a second string + * + * @param {Number} ms + * @return {String} + */ +function time(ms) { + if (ms < 1000) { + return (ms.toFixed(0)) + 'ms'; + } + + return (ms / 1000).toFixed(2) + 's'; +} + +/** + * Dump all timers to a logger + * @param {Logger} logger + */ +function dump(logger) { + const prefix = ' > '; + let measured = 0; + const totalDuration = Date.now() - startDate; + + // Enable debug logging + const logLevel = logger.getLevel(); + logger.setLevel('debug'); + + Immutable.Map(timers) + .valueSeq() + .sortBy(function(timer) { + measured += timer.total; + return timer.total; + }) + .forEach(function(timer) { + const percent = (timer.total * 100) / totalDuration; + + logger.debug.ln((percent.toFixed(1)) + '% of time spent in "' + timer.type + '" (' + timer.count + ' times) :'); + logger.debug.ln(prefix + 'Total: ' + time(timer.total) + ' | Average: ' + time(timer.total / timer.count)); + logger.debug.ln(prefix + 'Min: ' + time(timer.min) + ' | Max: ' + time(timer.max)); + logger.debug.ln('---------------------------'); + }); + + + logger.debug.ln(time(totalDuration - measured) + ' spent in non-mesured sections'); + + // Rollback to previous level + logger.setLevel(logLevel); +} + +module.exports = { + measure, + dump +}; diff --git a/packages/gitbook/testing/setup.js b/packages/gitbook/testing/setup.js new file mode 100644 index 0000000..ee1485e --- /dev/null +++ b/packages/gitbook/testing/setup.js @@ -0,0 +1,73 @@ +const is = require('is'); +const path = require('path'); +const fs = require('fs'); +const expect = require('expect'); +const cheerio = require('cheerio'); + +expect.extend({ + + /** + * Check that a file is created in a directory: + * expect('myFolder').toHaveFile('hello.md'); + */ + toHaveFile(fileName) { + const filePath = path.join(this.actual, fileName); + const exists = fs.existsSync(filePath); + + expect.assert( + exists, + 'expected %s to have file %s', + this.actual, + fileName + ); + return this; + }, + toNotHaveFile(fileName) { + const filePath = path.join(this.actual, fileName); + const exists = fs.existsSync(filePath); + + expect.assert( + !exists, + 'expected %s to not have file %s', + this.actual, + fileName + ); + return this; + }, + + /** + * Check that a value is defined (not null nor undefined) + */ + toBeDefined() { + expect.assert( + !(is.undefined(this.actual) || is.null(this.actual)), + 'expected to be defined' + ); + return this; + }, + + /** + * Check that a value is defined (not null nor undefined) + */ + toNotBeDefined() { + expect.assert( + (is.undefined(this.actual) || is.null(this.actual)), + 'expected %s to be not defined', + this.actual + ); + return this; + }, + + /** + * Check that a dom element exists in HTML + * @param {String} selector + */ + toHaveDOMElement(selector) { + const $ = cheerio.load(this.actual); + const $el = $(selector); + + expect.assert($el.length > 0, 'expected HTML to contains %s', selector); + } +}); + +global.expect = expect; |