diff --git a/Procfile b/Procfile index 14ff2c2..e449d3d 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: ./node_modules/.bin/rollup -c rollup-build.config.js -o dist/index.js -f iife -n UiZoo -g underscore:_,react:React,react-dom:ReactDOM,react-router-dom:ReactRouterDOM,doctrine:doctrine-standalone,babel-standalone:Babel && node server/main.js \ No newline at end of file +web: ./node_modules/.bin/rollup -c rollup.config.js -o dist/index.js -f iife -n UiZoo && node lib/server/main.js \ No newline at end of file diff --git a/README.md b/README.md index bab04f0..c62b6b8 100644 --- a/README.md +++ b/README.md @@ -17,32 +17,43 @@ This tool can be used for developing, for Product Managers to know what is possi ![React UiZoo 3](https://imgur.com/f3B2TDj.gif) ## How To UiZoo? -Git clone by: + +Just use our zero-configuration CLI! it's easy as pie! 🍽 + ``` -git clone git@github.com:myheritage/uizoo.js.git +npm i -g uizoo ``` -then + +In a directory, do: ``` -cd uizoo.js && npm i -gulp +uizoo ``` -This will start a server on http://localhost:5000 with the UiZoo -you can change the [components file](https://github.com/myheritage/uizoo.js/blob/master/client/components.js) and the [documentation file](https://github.com/myheritage/uizoo.js/blob/master/client/documentation.js) to start rapidly. -We recommend updating those files by a script automatically when files are changing (we plan to create plugins to help with this in the next future). -*or* npm install by: +It will create a [webpack development server](https://webpack.js.org/configuration/dev-server/) fully configured with [Hot Module Replacement](https://webpack.js.org/concepts/hot-module-replacement/) to watch your files while you develop! + +For example: + +![React UiZoo CLI](https://imgur.com/v3PbP8U.gif) + +Start the server with the newly added script: ``` -npm i -S uizoo +npm start uizoo ``` -then in your code, add: + +### Customization +The CLI creates a directory called `uizoo-app`, in it there is a file called `config.js` that determine basic stuff like the server's port, glob to find your components and more. There is also a very simple webpack configuration called `webpack.uizoo.js`. + + +### Local installation +*If you don't want to install UiZoo globally, you can instead do:* ``` -import 'uizoo/dist/index.css'; -import UiZoo from 'uizoo'; -UiZoo.init(documentation, components, rootElement); +npm i -D uizoo && ./node_modules/.bin/uizoo ``` -### init +### API ``` +import UiZoo from 'uizoo'; + UiZoo.init(documentation: Object, components: Object, rootElement: HTMLElement?, baseRoute: String?) ``` @@ -50,7 +61,7 @@ UiZoo.init(documentation: Object, components: Object, rootElement: HTMLElement?, **components** - Object, mapping of components name to components. See [example](https://github.com/myheritage/uizoo.js/blob/master/client/components.js). -**rootElement** - HTMLElement, will bootstrap UiZoo on that Element. Default is an element with the id 'library-_-root' +**rootElement** - HTMLElement, will bootstrap UiZoo on that Element. Default is a new element on the body. **baseRoute** Route to be the base before the UiZoo routes. Default to '/'. for example if the UiZoo is on your site like so: 'www.mysite.com/my/zoo/', the base route should be '/my/zoo/'. @@ -108,7 +119,7 @@ To add tests, use the following steps - First, make sure the app is up and running: ``` -gulp +npm start ``` The first time tests are run, install the npm dependencies: ``` diff --git a/bin/uizoo.js b/bin/uizoo.js new file mode 100644 index 0000000..207ce01 --- /dev/null +++ b/bin/uizoo.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +const chalk = require('chalk'); +const logo = require('../lib/logo'); +const log = console.log.bind(console); + +log(` +Welcome to the ~ + ${chalk.bold(chalk.cyan(logo))} +`); + +require('../lib/generate')(); \ No newline at end of file diff --git a/client/Components/UI/Tooltip/UiTooltip/index.js b/client/Components/UI/Tooltip/UiTooltip/index.js index b1e68ac..7894f3b 100644 --- a/client/Components/UI/Tooltip/UiTooltip/index.js +++ b/client/Components/UI/Tooltip/UiTooltip/index.js @@ -1,4 +1,5 @@ import React from 'react'; +import _ from 'underscore'; import {SIDES, SIDE_TOP, ALIGNMENTS, ALIGNMENT_CENTER, TRIGGER_EVENTS, TRIGGER_EVENT_HOVER} from './constants'; import './index.scss'; diff --git a/client/Components/UI/Tooltip/autoLocationDetector/autoLocationDetector.js b/client/Components/UI/Tooltip/autoLocationDetector/autoLocationDetector.js index 416e859..bd80331 100644 --- a/client/Components/UI/Tooltip/autoLocationDetector/autoLocationDetector.js +++ b/client/Components/UI/Tooltip/autoLocationDetector/autoLocationDetector.js @@ -1,3 +1,4 @@ +import _ from 'underscore'; import { SIDE_TOP, SIDE_BOTTOM, diff --git a/client/index.js b/client/index.js index 4362f2e..cb7a55a 100644 --- a/client/index.js +++ b/client/index.js @@ -1,6 +1,7 @@ import './index.scss'; import React from 'react'; import ReactDOM from 'react-dom'; +import _ from 'underscore'; import { BrowserRouter, Route } from 'react-router-dom'; import libraryData from './components'; @@ -9,22 +10,26 @@ import { checkDependencies } from './services/checkHealth'; import { createCompiler } from './services/compileWithContext'; import { parseDocumentation } from './services/parseDocumentation'; import App from './Components/App'; -import mapComponentsByModule from "./services/componentByModuleMapper"; +import mapComponentsByModule from './services/componentByModuleMapper'; -const defaultRoot = document.getElementById('library-_-root'); +window._extend = _.extend; // to be used instead of Object.assign /** - * Init - * @param {Object} documentation - * @param {Object} components - * @param {HTMLElement} rootElement + * Init - render UiZoo with documentation and components mappings + * @param {Object} [documentation] + * @param {Object} [components] + * @param {HTMLElement} [rootElement] will default to a new element on the body */ function init( documentation = libraryDocs, components = libraryData, - rootElement = defaultRoot, + rootElement, baseRoute = '/' ) { + if (!rootElement) { + rootElement = document.createElement('div'); + document.body.appendChild(rootElement); + } checkDependencies(documentation, components); const compiler = createCompiler(components); // JSX compiler diff --git a/client/services/compileWithContext.js b/client/services/compileWithContext.js index 5dd0c3c..5dd9b9a 100644 --- a/client/services/compileWithContext.js +++ b/client/services/compileWithContext.js @@ -1,6 +1,6 @@ import React from 'react'; import _ from 'underscore'; -import Babel from 'babel-standalone'; +import * as Babel from 'babel-standalone'; export function createCompiler(context) { const iframe = createIframe(); diff --git a/gulpfile.js b/gulpfile.js index 8df9afe..f55e4bd 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,8 +1,8 @@ let gulp = require('gulp'), rollup = require('rollup'), - getRollupConfig = require('./rollup.config'), + rConfig = require('./rollup.config'), chalk = require('chalk'), - nodemon = require("gulp-nodemon"), + nodemon = require('gulp-nodemon'), livereload = require('gulp-livereload'), execSync = require("child_process").execSync; @@ -31,20 +31,13 @@ gulp.task("watch", () => { function bundleClient() { updateDocumentation(); - return rollup.rollup(getRollupConfig({external: ['underscore', 'react', 'react-dom', 'react-router-dom', 'doctrine-standalone', 'babel-standalone']})) + return rollup.rollup(rConfig) .then(bundle => { bundle.write({ format: 'iife', file: 'dist/index.js', - globals: { - 'underscore': '_', - 'react': 'React', - 'react-dom': 'ReactDOM', - 'react-router-dom':'ReactRouterDOM', - 'doctrine-standalone': 'doctrine', - 'babel-standalone': 'Babel', - }, name: 'UiZoo', + globals: rConfig.globals }); }) .catch(handleError); @@ -52,7 +45,7 @@ function bundleClient() { function startNodemonServer() { nodemonStream = nodemon({ - script: './server/main.js', + script: './lib/server/main.js', ext: 'js html', watch: false, }) @@ -78,7 +71,7 @@ function handleError(error) { function updateDocumentation() { try { - execSync(`node documentationMapper.js "./client/Components/UI/*/index.js" "./client/Components/UI/(.+)/index.js" "./client/documentation.js"`); + execSync(`node lib/scripts/documentationMapper.js "./client/Components/UI/*/index.js" "./client/Components/UI/(.+)/index.js" "./client/documentation.js"`); } catch (err) { console.error(err.message); diff --git a/index.html b/index.html index f928fcd..1a9f3e3 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,6 @@
- diff --git a/lib/generate/config.js b/lib/generate/config.js new file mode 100644 index 0000000..99b1e26 --- /dev/null +++ b/lib/generate/config.js @@ -0,0 +1,39 @@ +const path = require('path'); + +const appFolderPath = path.resolve('uizoo-app'); +const uiZooScript = `node ${path.join('uizoo-app', 'webpack.uizoo.js')}`; + +const neededPackages = [ + 'uizoo', + 'webpack', + 'webpack-dev-server', + 'babel-loader', + 'babel-preset-env', + 'babel-preset-react', + 'babel-plugin-transform-object-rest-spread', + 'babel-plugin-syntax-dynamic-import', + 'style-loader', + 'css-loader', + 'fs-extra', + 'glob-stream' +]; + +const templatesToCopy = [ + 'index.html', + 'index.js', + 'webpack.uizoo.js', + 'createConfigsScript.js', + 'componentsContainer.js', + 'documentationContainer.js', + 'config.js', +]; + +const log = console.log.bind(console); + +module.exports = { + appFolderPath, + uiZooScript, + neededPackages, + templatesToCopy, + log +}; \ No newline at end of file diff --git a/lib/generate/index.js b/lib/generate/index.js new file mode 100644 index 0000000..9a7214c --- /dev/null +++ b/lib/generate/index.js @@ -0,0 +1,23 @@ +const fs = require('fs-extra'); +const opn = require('opn'); + +const {updatePackageJson} = require('./packageJsonService'); +const {copyTemplate} = require('./templatesService'); +const {executeCommand} = require('./processService'); +const {log, templatesToCopy, appFolderPath} = require('./config'); + +function generate() { + log(' ~ Copying templates...\n'); + updatePackageJson() + .then(() => fs.ensureDir(appFolderPath)) + .then(() => Promise.all(templatesToCopy.map(copyTemplate))) + .then(() => log(' ~ Executing commands...\n')) + .then(() => executeCommand('npm i')) + .then(() => {setTimeout(() => opn('http://localhost:5005/uizoo'), 2500)}) + .then(() => log(' ~ Done! Executing `uizoo` script:\n')) + .then(() => executeCommand('npm run uizoo')) + .then(() => process.exit(0)) + .catch(e => console.error(e)); +} + +module.exports = generate; \ No newline at end of file diff --git a/lib/generate/packageJsonService.js b/lib/generate/packageJsonService.js new file mode 100644 index 0000000..781c7f1 --- /dev/null +++ b/lib/generate/packageJsonService.js @@ -0,0 +1,55 @@ +const path = require('path'); +const latestVersion = require('latest-version'); +const fs = require('fs-extra'); +const {resolveTemplatePath} = require('./templatesService'); +const {uiZooScript, neededPackages} = require('./config'); + +module.exports = { + updatePackageJson +}; + +/** + * Update the folder's package.json with needed dependencies or use a fresh one from templates + * if there is none + * @return {Promise} + */ +function updatePackageJson() { + return new Promise((resolve, reject) => { + const pkgPath = path.resolve('package.json'); + const defaultPkgPath = resolveTemplatePath('package.json'); + + fs.exists(pkgPath) + .then(exists => fs.readFile(exists ? pkgPath : defaultPkgPath)) + .then(modifyPackage) + .then(pkg => fs.writeJSON(pkgPath, pkg, {spaces: 2})) + .then(resolve) + .catch(reject); + }); +} + +function modifyPackage(packageBuffer) { + return new Promise((resolve, reject) => { + Promise.all(neededPackages.map(latestVersion)) + .then(neededPackagesVersions => { + let pkg = JSON.parse(packageBuffer.toString()); + + // Add dependencies + neededPackages.forEach((neededPackage, i) => { + if((!pkg.dependencies || !pkg.dependencies[neededPackage]) && + (!pkg.devDependencies || !pkg.devDependencies[neededPackage])) { + + pkg.devDependencies = Object.assign({}, pkg.devDependencies, { + [neededPackage]: `~${neededPackagesVersions[i]}` + }); + } + }); + + // Add scripts + pkg.scripts = Object.assign({}, pkg.scripts, { + "uizoo": uiZooScript + }); + resolve(pkg); + }) + .catch(reject); + }); +} \ No newline at end of file diff --git a/lib/generate/processService.js b/lib/generate/processService.js new file mode 100644 index 0000000..8ba6658 --- /dev/null +++ b/lib/generate/processService.js @@ -0,0 +1,21 @@ +const {spawn} = require('child_process'); +const chalk = require('chalk'); +const {log} = require('./config'); + +module.exports = { + executeCommand +}; + +/** + * Execute a command to terminal with inherit io, resolve when it is done + * @param {String} cmd + * @return {Promise} + */ +function executeCommand(cmd) { + return new Promise((resolve, reject) => { + log(chalk.grey(` ${cmd}\n`)); + let cmdParts = cmd.split(' '); + const ls = spawn(cmdParts.shift(), [].concat(cmdParts), {stdio: "inherit"}); + ls.on('close', resolve); + }); +} \ No newline at end of file diff --git a/lib/generate/templates/componentsContainer.js b/lib/generate/templates/componentsContainer.js new file mode 100644 index 0000000..4b0f9fb --- /dev/null +++ b/lib/generate/templates/componentsContainer.js @@ -0,0 +1 @@ +export default null; \ No newline at end of file diff --git a/lib/generate/templates/config.js b/lib/generate/templates/config.js new file mode 100644 index 0000000..0af62b7 --- /dev/null +++ b/lib/generate/templates/config.js @@ -0,0 +1,58 @@ +const path = require('path'); + +/** + * Dev Server config + */ +const serverPort = 5005; +const serverProtocol = process.env.HTTPS === 'true' ? 'https' : 'http'; +const serverHost = process.env.HOST || '0.0.0.0'; + +/** + * The dir where the original command to create UiZoo was originated from, + * it is where the node_modules are and where to look for components from + */ +const componentsRootDir = path.dirname(__dirname); + +/** + * TIL that glob use '/' separators everywhere! + */ +const componentsRootsDirGlob = path.sep === '\\' ? componentsRootDir.split(path.sep).join('/') : componentsRootDir; + +/** + * Glob to fetch all components for UiZoo + * It exclude node_modules & uizoo-app directories + * If you have a specific convention to your Components names, or specific sub-libraries, you should add it + * + * You can provide either a single glob or an array that will be aggregated + * (using 'glob-stream', so you can negate and stuff, see: https://github.com/gulpjs/glob-stream) + */ +const componentsGlob = [ + `${componentsRootsDirGlob}/**/*.js`, + `!${componentsRootsDirGlob}/node_modules/**/*`, + `!${componentsRootsDirGlob}/uizoo-app/**/*`, +]; + +/** + * Add this tag to a component JSDoc will exclude it from the config files of UiZoo + * @example + * This will exclude this component: + * @componentLibraryIgnore + */ +const ignoreTag = 'componentLibraryIgnore'; + +/** + * Strategy to decide on the file's JSDoc by a regex + * It will take the first JSDoc comment answering this regex + * It should have either a @description or @example tag in it + */ +const componentMainCommentRegex = /\/\*\*(\s*\*\s*.*?)*@(description|example).*(\s*\*.*)*/g; + +module.exports = { + serverPort, + serverProtocol, + serverHost, + componentsRootDir, + componentsGlob, + ignoreTag, + componentMainCommentRegex, +}; \ No newline at end of file diff --git a/lib/generate/templates/createConfigsScript.js b/lib/generate/templates/createConfigsScript.js new file mode 100644 index 0000000..40224a2 --- /dev/null +++ b/lib/generate/templates/createConfigsScript.js @@ -0,0 +1,198 @@ +const gs = require('glob-stream'); +const path = require('path'); +const fs = require('fs-extra'); +const doctrine = require('doctrine'); + +const { + ignoreTag, + componentMainCommentRegex, + componentsGlob, + componentsRootDir +} = require('./config'); + +module.exports = createConfigs; + +/** + * Create 2 config files + * One is a mapping between components names to actual components + * and another is a mapping between components names to their documentation + * @return {Promise} + */ +function createConfigs() { + return promiseGlob(componentsGlob) + .then(filePaths => readFiles(filePaths) + .then(filesData => processFiles(filesData, filePaths)) + ) + .then(writeFiles); +} + +/** + * @param {Array} filePaths + * @return {Promise} + */ +function readFiles(filePaths) { + return Promise.all(filePaths.map(filePath => fs.readFile(filePath))); +} + +/** + * Process components files and output a map that we can write to files + * @param {Array} filesData + * @param {Array} filePaths + * @return {Map} of component name to an object in the form {filePath, comment} + */ +function processFiles(filesData = [], filePaths = []) { + let componentsMap = new Map(); + + filesData.forEach((fileDataBuffer, i) => { + const fileData = fileDataBuffer.toString(); + // Get component main comment + const matches = fileData.match(componentMainCommentRegex); + if (matches && matches.length) { + // Choosing the first comment that matched the regex + let comment = matches[0], + filePath = filePaths[i]; + + const parsedComment = parseCommentToObject(comment); + // skip this components if it have an ignore tag + if (!parsedComment[ignoreTag]) { + const componentName = getComponentName(parsedComment, filePath); + componentsMap.set(componentName, {filePath, comment}); + } + } + }); + + return componentsMap; +} + +/** + * Write the map data to files + * It will sort the component names + * It will skip the writing if there is nothing to write or nothing had changed + * + * @param {Map} componentsMap + * @return {Promise} + */ +function writeFiles(componentsMap) { + let componentsKeys = [...componentsMap.keys()]; + if (!componentsKeys.length) return null; + let docMap = new Map(), + comMap = new Map(); + + componentsKeys.sort(); + componentsKeys.forEach(componentName => { + let componentConf = componentsMap.get(componentName); + docMap.set(componentName, componentConf.comment); + comMap.set(componentName, componentConf.filePath); + }); + + return Promise.all([ + writeIfDifferent('documentationContainer.js', createDocumentationFile(docMap)), + writeIfDifferent('componentsContainer.js', createComponentsFile(comMap)) + ]); +} + +/** + * Strategy to figure out the component name + * + * @param {Object} parsedComment + * @param {String} filePath + */ +function getComponentName(parsedComment, filePath) { + let name; + if (parsedComment.name && parsedComment.name[0] && parsedComment.name[0].name) { + // Option 1: name in JSDoc @name tag + name = parsedComment.name[0].name; + } else { + let fileParts = path.parse(filePath) || {}; + if (fileParts.name && !(/^index/.test(fileParts.name))) { + // Option 2: try to get name from file name, but make sure it's not index.~ something + name = fileParts.name; + } else { + // Option 3: nothing left - take dir name + name = path.basename(fileParts.dir); // parent's directory + } + } + return name; +} + +/** + * Create the documentation file from the map + * @param {Map} docMap + * @return {String} + */ +function createDocumentationFile(docMap) { + let docFile = 'export default {\n'; + for (let [componentName, comment] of docMap) { + // escaping ` and ${} by \` and \${} + docFile += `${componentName}: \`${comment.replace(/\`/g, '\\`').replace(/\$\{/g, '\\${')}\`,\n`; + } + docFile += '}'; + return docFile; +} + +/** + * Create the components file from the map + * @param {Map} comMap + * @return {String} + */ +function createComponentsFile(comMap) { + let comFile = '', + exportLine = 'export default {\n'; + for (let [componentName, filePath] of comMap) { + comFile += `import ${componentName} from '${filePath.replace(componentsRootDir, '..')}';\n`; + exportLine += ` ${componentName},\n`; + } + comFile += `${exportLine}};`; + return comFile; +} + +/** + * Write only if current content is different to prevent + * @param {String} fileName + * @param {String} fileContent + * @return {Promise} + */ +function writeIfDifferent(fileName, fileContent) { + const filePath = path.join(componentsRootDir, 'uizoo-app', fileName); + return new Promise((resolve, reject) => { + fs.readFile(filePath) + .then(fileBuffer => fileContent !== fileBuffer.toString()) + .then(shouldWrite => shouldWrite ? fs.writeFile(filePath, fileContent) : true) + .then(resolve) + .catch(reject); + }); +} + +/** + * Us doctrine to transform String comment to an Object + * @param {String} comment + * @return {Object} + */ +function parseCommentToObject(comment) { + const {tags = []} = doctrine.parse(comment, {unwrap: true, recoverable: true, sloppy: true}); + let doc = {}; + tags.forEach(tag => { + doc[tag.title] = doc[tag.title] || []; + doc[tag.title].push(tag); + }); + return doc; +} + +/** + * Turn the glob stream to a promise that reslove the files + * @param {Array|String} globs + * @return {Promise} + */ +function promiseGlob(globs) { + return new Promise((resolve, reject) => { + let files = []; + let ls = gs(componentsGlob); + ls.on('data', (f = {}) => { + if (f.path) files.push(f.path); + }); + ls.on('error', reject); + ls.on('end', () => { + resolve(files); + }); + }); +} \ No newline at end of file diff --git a/lib/generate/templates/documentationContainer.js b/lib/generate/templates/documentationContainer.js new file mode 100644 index 0000000..4b0f9fb --- /dev/null +++ b/lib/generate/templates/documentationContainer.js @@ -0,0 +1 @@ +export default null; \ No newline at end of file diff --git a/lib/generate/templates/index.html b/lib/generate/templates/index.html new file mode 100644 index 0000000..7110a54 --- /dev/null +++ b/lib/generate/templates/index.html @@ -0,0 +1,13 @@ + + + + +