UNPKG

@gtdudu/react-gen-routes

Version:

Generate config for react-router-config based on directory architecture

638 lines (499 loc) 17.3 kB
import Promise from 'bluebird'; import fs from 'fs'; import path from 'path'; import _ from 'lodash'; import debug from 'debug' import fsu from './fs-utils'; import stru from './str-utils'; import tmplu from './tmpl-utils'; import getCode from './babel'; const logger = debug('rgr'); class Engine { constructor(options = {}) { const optsOk = _.isString(options.inputDir) && _.isString(options.outputDir) && _.isString(options.filename) && ( _.isUndefined(options.templatesDir) || _.isString(options.templatesDir) ) && _.isBoolean(options.watch) ; if (!optsOk) { throw new Error('[Engine.constructor] invalid options.'); } this._keywords = options.keywords; // this._parseComponent = options.ba // coma separated list of accepted extensions or undefined this._extensions = options.extensions; // folder that we need to analyse this._inputDir = options.inputDir; // folder where we want to write routes file this._outputDir = options.outputDir; // name of the output routes file this._filename = options.filename; // folder holding component and imports template this._templatesDir = options.templatesDir; // true to recompute routes file on inputDir changes this._watch = options.watch; // necessary to properly sort routes this._parentDirInfo = { isDynamicDir: false, isNestedDir: false, hasDynamicFile: false, }; this._cache = {}; } setExtentions(){ if (!this._extensions) { this._extensions = ['js']; return; } const extensions = _.split(this._extensions, ','); const trimed = _.map(_.compact(extensions), (ext) => { return _.trim(ext); }); this._extensions = trimed; } setKeywords() { if (!this._keywords) { this._keywords = []; return; } const keywords = _.split(this._keywords, ','); const trimed = _.map(_.compact(keywords), (word) => { return _.trim(word); }); this._keywords = trimed; this._shouldParse = _.size(trimed); } async safeRun() { try { await this.run(); logger('All good: routes.js has been generated'); } catch (e) { logger('engine failed to run', e); } } async run() { // get accepted files extensions this.setExtentions(); this.setKeywords(); // create uniq temporary directory this._tmpFolder = await fsu.createTmpDir(); // get relative path between outputDir and inputDir this._relativePath = path.relative(this._outputDir, this._inputDir); // compute routes file const routes = await this.getAllRoutes(this._inputDir); // write json file to tmp dir const jsonRoutesPath = path.join(this._tmpFolder, 'routes.js'); await fsu.writeFile(jsonRoutesPath, JSON.stringify(routes, null, 2)); // compute js file from json file const routesFile = await tmplu.fillRoutesTemplate(this._tmpFolder, this._templatesDir); // write js file to outputDir const outputPath = path.join(this._outputDir, this._filename); await fsu.writeFile(outputPath, routesFile); // run is over, ready to run again this.ready = true; // return if watch mode is off if (!this._watch) { // watch mode is off return; } // if we're not already watching for changes in inputDir, start watch if (!this.watching) { await this.watchDir(); logger('Watching for changes...'); } // nothing changed while we were running, return if (!this.shouldRerun) { return; } // we received a change event while we were already runing, start over logger('DEBUG: run is over but got event in the meantime! re running'); this.shouldRerun = false; await this.run(); } handleCache(type, cmpPath) { if (!this._shouldParse) { return; } const absolutePath = path.resolve('.', cmpPath); if (type === 'unlink' || type === 'change') { delete this._cache[absolutePath]; } } async watchDir() { // input dir must be a String path that points to a folder if (!_.isString(this._inputDir) || !fsu.isDir(this._inputDir)) { throw new Error('[watch] {inputDir} does not point to a directory'); } try { // recursively get the list of all files in inputDir const list = await fsu.deepLs(this._inputDir); const size = _.size(list); // chokidar will emit 'add' events when starting the watch const chokidar = require('chokidar'); // since we already generate routes on startup do not relaunch unless counter is equal to initial list size let counter = 0; this.watching = true; return new Promise((resolve, reject) => { this._resolve = resolve; chokidar.watch(this._inputDir) // start watch .on('all', async(event, path) => { counter++; // init is not over if (counter < size) { return; } if (this._resolve) { const resolve = this._resolve; this._resolve = null; return resolve(); } this.handleCache(event, path); logger(`[watch] '${event}' => ${path}`); // make sure we're not ready to run again if we catch another events if (!this.ready) { this.shouldRerun = true; return; } this.ready = false; await this.run(); logger('# routes.js has been regenerated'); }) ; }) } catch (err) { logger('[watch]: ', err); } } async getAllRoutes(from) { const routesConfig = {}; // make sure from points to a directory const isDir = await fsu.isDir(from); if (!isDir) { throw new Error('from is not a directory'); } const routes = await this.getRoutes(from, this._parentDirInfo); routesConfig.routes = routes; return routesConfig; } // given a directory path and a list of filenames in it // returns Array<Object> { name, isDir, filePath } // sorted with files first and folder second // fileNames that do not match component convention (1 dot) or that do not have an extension listed in _extensions won't be returned async sortAndFilter(from, names) { const files = []; const folders = []; await Promise.each(names, async(name) => { const filePath = path.join(from, name); const isDir = await fsu.isDir(filePath); if (isDir) { folders.push({ name, isDir, filePath }); return; } // keep only filenames that point to components: // - need to end in '.js' // - need to have only 1 dot // a.js -> cmp // a.style.js, a.whatever.js, ... -> not cmp // TODO: find a way not to impose those rules... // maybe pass a regex as args ? // or a path to a function ? // should at least be able to select extension const splitted = _.split(name, '.'); if (_.size(splitted) !== 2 || !_.includes(this._extensions, _.last(splitted))) { return; } files.push({ name, isDir, filePath }); }); return [...files, ...folders]; } hasNested(items, nameNoExt) { const exist = _.find(items, (check) => { return check.name === nameNoExt; }); return Boolean(exist); } isNested(items, nameNoExt) { const name = nameNoExt + '.js'; const exist = _.find(items, (check) => { return check.name === name; }); return Boolean(exist); } // scores (from 1 to 5) will be needed for sorting later on // beware, this function mutates items getSortScore(items, parentDirInfo) { _.each(items, (item) => { // index file in dynamic directory need to be push last if (item.nameNoExt === 'index' && parentDirInfo.isDynamicDir) { item.score = 5; return; } // all other index file need to be pushed first if (item.nameNoExt === 'index') { item.score = 1; return; } // non dynamic file should be right after index files if (!item.isDynamic && !item.isDir) { item.score = 2; return; } // non dynamic folders come after index and non dynamic files if (!item.isDynamic) { item.score = 3; return; } // dynamic folders must be one to last if (item.isDir) { item.score = 4; return; } // dynamic file need to be last // this does not conflict with index files in a dynamic folder since we cannot have both at the same time item.score = 5; }); } async getInfo(from, parentDirInfo, items) { // track how many dynamic files and folders we find let dynamicFolderCount = 0; let dynamicFileCount = 0; let dynamicIndexDir = false; // remove _inputDir from parent folder path // handle _inputDir starting with './' // used to compute relative path between _outputDir and items const cleanInput = from .replace(this._inputDir, '') .replace(this._inputDir.slice(2), '') ; // compute infos const infos = await Promise.map(items, async (item) => { const d = { ...item }; // folder base info if (item.isDir) { d.nameNoExt = item.name; d.isNested = this.isNested(_.without(items, item), d.nameNoExt); dynamicIndexDir = d.isNested && d.nameNoExt === 'index'; } // file base info if (!item.isDir) { d.nameNoExt = stru.stripExtension(item.name); d.hasNested = this.hasNested(_.without(items, item), d.nameNoExt); d.routePath = await stru.createRoutePath(this._inputDir, from, d.nameNoExt); } // prevent files and folders named * as they would create conflicting routes if (item.nameNoExt === '*') { logger(`skipping '${item.filePath}' (* is a forbidden name).`); return; } // check if dynamic let isDynamic; try { d.isDynamic = stru.isDynamic(d.nameNoExt); } catch (e) { logger(`skipping file '${item.filePath}' (invalid name).`); return; } // skip index file if we are in a dynamic folder and parent folder holds // a dynamic file since they would virtually resolve to the same react router path // // test/ // [id].js -> /test/:id // [param] // index.js -> /test/:param if (d.nameNoExt === 'index' && parentDirInfo.hasDynamicFile && parentDirInfo.isDynamicDir) { logger(`skipping file ${item.filePath} (conflict with parent folder dynamic file)`); return; } if (d.isDir && d.isDynamic) { dynamicFolderCount++; // skip dynamic folder if we already have one in directory // We could merge both folder and check for conflicts but seems to me that the price is low compared to the loss of clarity if (dynamicFolderCount > 1) { logger(`skipping folder '${item.filePath}' (dynamic folder already exists).`); return; } } if (!item.isDir && d.isDynamic) { dynamicFileCount++; // skip dynamic file if we already have one in directory since they would virtually resolve to the same react router path // // test/ // [a].js -> /test/:a // [b].js -> /test/:b if (dynamicFileCount > 1) { logger(`skipping file '${item.filePath}' (dynamic file already exists).`); return; } } if (!item.isDir) { const relativeFolderPath = path.join(this._relativePath, cleanInput); d.relativeFilePath = `${relativeFolderPath}/${item.name}`; if (!d.hasNested) { return d; } return d; } // skip empty folder // this if is mainly for debug as it would make no difference to remove it if (!await fsu.hasFiles(item.filePath)) { logger(`skipping folder '${item.filePath}' (empty folder).`); return; } return d; }); // remove falsy values let keepInfos = _.compact(infos); // if we have dynamic index folder, all other dynamic path will never be reached since we have to set exact false to make nested routes working and the route will end up being first in the config object.. // => remove all other file/folder and log warning if (dynamicIndexDir) { const keep = _.filter(keepInfos, (info) => { if (info.nameNoExt === 'index') { return true; } const type = info.isDir ? 'folder' : 'file'; logger(`skipping ${type} ${info.filePath} (nested index would prevent this route from ever being reached)`); return }); return [keep, dynamicFileCount, dynamicFolderCount]; } return [keepInfos, dynamicFileCount, dynamicFolderCount]; } // list all items in a given folder // and returns extensive info for each // empty folder will be ignored async lsDetails(from, parentDirInfo) { // list all items in folder const names = await fsu.ls(from); // returns an array where files are first and folders second // this is required for getInfo to work properly: // - dynamic folders need to know if parent folder has a dynamic file which would not work if items weren't treated first const items = await this.sortAndFilter(from, names); // get extensive info for all files in current folder and dynamic counters const [ infos, dynamicFileCount, dynamicFolderCount ] = await this.getInfo(from, parentDirInfo, items); // compute sorting scores this.getSortScore(infos, parentDirInfo); // split files, folders, and nested folders for easier consumption const map = _.reduce(infos, (acc, item, index) => { // all files if (!item.isDir) { acc.files[item.nameNoExt] = item; return acc; } // only folders can be nested if (item.isNested) { acc.nested[item.nameNoExt] = item; return acc; } acc.folders[item.nameNoExt] = item; return acc; }, { files: {}, folders: {}, nested: {}, }); return [map, dynamicFileCount]; } async handleKeywords(absolutePath) { if (!this._shouldParse) { return {}; } if (this._cache[absolutePath]) { const scope = this._cache[absolutePath]; return scope; } const scope = await getCode(absolutePath, this._keywords); this._cache[absolutePath] = scope; return scope; } async handleFiles(items) { const keepFiles = []; const retainFiles = []; // files always take precedence over folders const fileNames = _.keys(items.files); await Promise.map(fileNames, async (name) => { const item = items.files[name]; const p = path.resolve('.', item.filePath); const scope = await this.handleKeywords(p); if (!item.hasNested) { const file = { ...scope, score: item.score, componentPath: item.relativeFilePath, path: item.routePath, exact: true, }; if (file.score === 5) { retainFiles.push(file); return; } keepFiles.push(file); return; } const nestedFolder = items.nested[item.nameNoExt]; const dirInfo = _.assign({}, this._parentDirInfo, { isDynamicDir: nestedFolder.isDynamic && nestedFolder.isDir, isNestedDir: true, }) const routes = await this.getRoutes(nestedFolder.filePath, dirInfo); const file = { ...scope, score: item.score, componentPath: item.relativeFilePath, path: item.routePath, routes, exact: false, }; if (file.score === 5) { retainFiles.push(file); return; } keepFiles.push(file); }); return [ keepFiles, retainFiles ]; } async handleFolders(items, hasDynamicFile) { const keepDir = []; const retainDir = []; const folderNames = _.keys(items.folders); await Promise.map(folderNames, async (name) => { const item = items.folders[name]; const dirInfo = _.assign({}, this._parentDirInfo, { isDynamicDir: item.isDynamic && item.isDir, hasDynamicFile, }) const subLevel = await this.getRoutes(item.filePath, dirInfo); if (item.score === 4) { retainDir.push(...subLevel); return; } keepDir.push(...subLevel); }); return [keepDir, retainDir]; } async getRoutes(from, parentDirInfo) { // list everything in directory with extensive info const [items, hasDynamicFile] = await this.lsDetails(from, parentDirInfo); const [filesStart, filesEnd ] = await this.handleFiles(items); const [foldersStart, foldersEnd ] = await this.handleFolders(items, hasDynamicFile); const res = [ ..._.sortBy(filesStart, 'score'), ...foldersStart, ...foldersEnd, ..._.sortBy(filesEnd, 'score'), ]; return _.map(res, (o) => { delete o.score; return o; }); } } export default Engine;