wesm87/wp-project-manager

View on GitHub
src/include/scaffold.ts

Summary

Maintainability
C
1 day
Test Coverage
/**
 * @module
 */

import path from 'path';
import fs from 'fs-extra';
import cp from 'child_process';
import mustache from 'mustache';
import { pathOr, isEmpty } from 'ramda';
import { camelCase, startCase } from 'lodash/fp';
// @ts-ignore
import { mock } from 'mocktail';

import log from './log';
import { getConfig, getPaths } from './project';

import { isTest } from './utils/env';

import {
  readDir,
  fileExists,
  symlinkExists,
  directoryExists,
} from './utils/fs';

/**
 * Project files that need to be symlinked, removed, or any other special
 * type of action that we can't determine automatically based on template
 * files or project configuration.
 */
const FILES = {
  bedrock: {
    remove: new Set([
      'composer.*',
      '*.md',
      'phpcs.xml',
      'wp-cli.yml',
      '.gitignore',
      '.travis.yml',
      '.env.example',
      '.editorconfig',
    ]),
  },
};

/**
 * Scaffolds out project files, plugins, themes, etc.
 *
 * @todo Figure out better names for functions.
 * @todo Document all the things! Maybe switch to rspec?
 * @todo Break this up into multiple files with individual functions instead of a class.
 *       - Scaffold functions should only handle project files and folders.
 *       - Move WordPress / database setup into separate file.
 *       - Move git commands into separate file?
 */
class Scaffold {
  /**
   * Gets base path to a specific type of file.
   */
  static async getBasePath(type: string = 'project'): Promise<string> {
    const { plugin, theme } = await getConfig();
    const paths = getPaths();

    const basePaths = {
      project: '.',
      vvv: 'vvv',
      scripts: 'scripts',
      bedrock: 'htdocs',
      wordpress: 'htdocs/web/wp',
      plugin: path.join('htdocs/web/app/plugins/', plugin.slug),
      theme: path.join('htdocs/web/app/themes/', theme.slug),
    };

    // We convert the type to camel case so we don't run into issues if we
    // want to use a type like `type-name` or `type_name`.
    const pathKey = camelCase(type);
    const base = basePaths[pathKey];

    if (!base) {
      return '';
    }

    return path.join(paths.project, base);
  }

  /**
   * Gets path to plugin or theme assets.
   */
  static async getAssetsPath(type: string = 'theme'): Promise<string> {
    const assetsPaths = {
      plugin: 'assets/source',
      theme: 'assets/source',
    };

    const pathKey = camelCase(type);
    const assetsPath = assetsPaths[pathKey];

    if (!assetsPath) {
      return '';
    }

    const basePath = await this.getBasePath(type);

    return path.join(basePath, assetsPath);
  }

  /**
   * Sets initial values required for other class methods.
   * Also creates the project folder if it doesn't exist.
   */
  static async init() {
    const config = await getConfig();
    const paths = getPaths();

    if (isTest(config.env)) {
      await fs.remove(paths.project);
    }

    await fs.mkdirp(paths.project);

    return true;
  }

  /**
   * Creates a new project.
   */
  static async createProject(): Promise<boolean> {
    const { project } = await getConfig();

    if (!project || !project.title) {
      log.error('You must specify a project title.');
      log.info('Check the README for usage information.');

      return false;
    }

    await this.initProjectFiles();
    await this.initRepo();
    await this.initProject();
    await this.initPlugin();
    await this.initTheme();

    return true;
  }

  /**
   * Creates project files.
   */
  static async initProjectFiles() {
    const config = await getConfig();

    await this.maybeCopyPluginZips();
    await this.parseTemplateData();
    await this.scaffoldFiles('scripts');

    if (config.vvv) {
      await this.scaffoldFiles('vvv');
    }
  }

  /**
   * Copies plugin ZIP files.
   */
  static async maybeCopyPluginZips() {
    const paths = getPaths();
    const dirExists = await directoryExists(paths.plugins);

    if (!dirExists) {
      return;
    }

    log.message('Copying plugin ZIPs...');

    const source = paths.plugins;
    const dest = path.join(paths.project, 'project-files/plugin-zips');

    await fs.copy(source, dest);

    log.ok('Plugin ZIPs copied.');
  }

  /**
   * Parses template data from project config.
   */
  static async parseTemplateData() {
    const paths = getPaths();
    const templateData = await getConfig();
    const pluginZipsDir = path.join(paths.project, 'project-files/plugin-zips');

    if (!templateData.pluginZips) {
      templateData.pluginZips = [];
    }

    const files = await readDir(pluginZipsDir);

    for (const file of files) {
      const name = path.basename(file, '.zip');
      templateData.pluginZips.push({ name, file });
    }
  }

  /**
   * Initializes the Git repo if enabled in project config.
   */
  static async initRepo(): Promise<boolean> {
    const paths = getPaths();
    const config = await getConfig();

    if (!config.repo.create) {
      return false;
    }

    log.message('Checking for Git repo...');

    const dirPath = path.join(paths.project, '.git');
    const dirExists = await directoryExists(dirPath);

    if (dirExists) {
      log.ok('Repo exists.');

      return false;
    }

    // Initialize repo.
    const gitInitResult = await this.exec('git init', 'project');

    if (gitInitResult) {
      log.ok('Repo initialized.');
    }

    // If the repo URL is set, add it as a remote.
    if (config.repo.url) {
      const command = `git remote add origin ${config.repo.url}`;
      const remoteAddResult = await this.exec(command, 'project');

      if (remoteAddResult) {
        log.ok('Remote URL added.');
      }
    }

    return true;
  }

  /**
   * Creates project files and install project dependencies.
   */
  static async initProject(): Promise<boolean> {
    const paths = getPaths();

    log.message('Checking for Bedrock...');

    const dirPath = path.join(paths.project, 'htdocs');
    const dirExists = await directoryExists(dirPath);

    if (dirExists) {
      log.ok('Bedrock exists');

      return false;
    }

    // Install Bedrock.
    const command = 'composer create-project roots/bedrock htdocs --no-install';
    const createProjectResult = await this.exec(command, 'project');

    if (createProjectResult) {
      log.ok('Bedrock installed.');
    }

    await this.linkFiles('project');
    await this.scaffoldFiles('project');
    await this.scaffoldFiles('bedrock');
    await this.removeFiles('bedrock');

    log.message('Installing project dependencies...');

    const installResult = await this.exec('composer install', 'project');

    if (installResult) {
      log.ok('Dependencies installed.');
    }

    return true;
  }

  /**
   * Creates plugin files.
   */
  static async initPlugin(): Promise<boolean> {
    const config = await getConfig();

    if (!config.plugin.scaffold) {
      return false;
    }

    if (!config.plugin.name) {
      log.error(
        'You must specify a plugin name.' +
          ' Check the README for usage information.',
      );

      return false;
    }

    log.message('Checking for plugin...');

    const basePath = await this.getBasePath('plugin');
    const dirExists = await directoryExists(basePath);

    if (dirExists) {
      log.ok('Plugin exists.');

      return false;
    }

    await this.scaffoldFiles('plugin');
    await this.createPlaceholders('plugin');

    log.ok('Plugin created.');

    return true;
  }

  /**
   * Creates plugin unit tests.
   */
  static createPluginTests() {
    log.error('This feature is not ready');
  }

  /**
   * Creates a child theme.
   *
   * @since 0.1.0
   */
  static async initTheme(): Promise<boolean> {
    const config = await getConfig();

    if (!config.theme.scaffold) {
      return false;
    }

    if (!config.theme.name) {
      const errorMessage =
        'You must specify a theme name.' +
        ' Check the README for usage information.';

      log.error(errorMessage);

      return false;
    }

    log.message('Checking for child theme...');

    const basePath = await this.getBasePath('theme');
    const dirExists = await directoryExists(basePath);

    if (dirExists) {
      log.ok('Child theme exists.');

      return true;
    }

    await this.scaffoldFiles('theme');
    await this.createPlaceholders('theme');
    await this.copyAssets('theme');

    log.ok('Theme created.');

    log.message('Installing theme dependencies...');

    await this.exec('npm install', 'theme');
    await this.exec('bower install', 'theme');

    log.message('Compiling theme assets...');

    await this.exec('npm run build', 'theme');

    log.ok('Done');

    return true;
  }

  /**
   * Creates theme unit tests.
   */
  static async createThemeTests() {
    log.error('This feature is not ready');

    return false;
  }

  /**
   * Executes a command.
   */
  static async exec(
    command: string,
    type: string = 'project',
  ): Promise<cp.ChildProcess> {
    const basePath = await this.getBasePath(type);

    return cp.exec(command, { cwd: basePath });
  }

  /**
   * Creates placeholder files and folders.
   */
  static async createPlaceholders(type: string = 'theme'): Promise<void> {
    const base = await this.getBasePath(type);

    const dirs = [
      'includes',
      'assets/source/css',
      'assets/source/js',
      'assets/source/images',
      'assets/source/fonts',
      'assets/dist/css',
      'assets/dist/js',
      'assets/dist/images',
      'assets/dist/fonts',
    ];

    const files = [
      'assets/dist/css/.gitkeep',
      'assets/dist/js/.gitkeep',
      'assets/dist/images/.gitkeep',
      'assets/dist/fonts/.gitkeep',
    ];

    for (const dir of dirs) {
      try {
        await fs.mkdirp(path.join(base, dir));
      } catch (error) {
        log.error(error);
      }
    }

    for (const file of files) {
      try {
        await fs.ensureFile(path.join(base, file));
      } catch (_unused) {
        // Do nothing.
      }
    }
  }

  /**
   * Copy an included set of plugin or theme assets.
   */
  static async copyAssets(
    type: string = 'theme',
    dir: string = '',
  ): Promise<boolean> {
    const paths = getPaths();
    const assetsPath = await this.getAssetsPath(type);

    const source = path.join(paths.assets, type, dir);
    const dest = path.join(assetsPath, dir);

    const dirExists = await directoryExists(source);

    if (!dirExists) {
      log.error(`${source} is not a valid assets folder.`);

      return false;
    }

    try {
      await fs.mkdirp(dest);
      await fs.copy(source, dest);

      const assetName = startCase(type);

      log.ok(`${assetName} assets created.`);
    } catch (error) {
      if (!isEmpty(error)) {
        log.error(error);
      }
    }

    return true;
  }

  /**
   * Creates symlinks to a set of files.
   */
  static async linkFiles(type: string = 'project') {
    const base = await this.getBasePath(type);
    const files = pathOr<any[]>([], [type, 'link'], FILES);

    if (isEmpty(files)) {
      return;
    }

    for (let [source, dest] of files) {
      const destBase = path.join(dest, path.basename(source));

      source = path.join(base, source);
      dest = path.join(base, destBase);

      log.message(`Checking for ${destBase}...`);

      const linkExists = await symlinkExists(dest);

      if (linkExists) {
        log.ok(`${dest} exists.`);
      } else {
        try {
          await fs.ensureSymlink(dest, source);
          log.ok(`${dest} created.`);
        } catch (error) {
          if (!isEmpty(error)) {
            log.error(error);
          }
        }
      }
    }
  }

  /**
   * Removes a set of files.
   */
  static async removeFiles(type: string = 'project') {
    const base = await this.getBasePath(type);
    const files = FILES[type].remove;

    if (!files) {
      return;
    }

    for (let file of files) {
      file = path.join(base, file);

      try {
        await fs.remove(file);
      } catch (error) {
        if (!isEmpty(error)) {
          log.error(error);
        }
      }
    }
  }

  /**
   * Renders a set of template files using the template data.
   */
  static async scaffoldFiles(type: string = 'project'): Promise<boolean> {
    const paths = getPaths();
    const source = path.join(paths.templates, type);

    const dirExists = await directoryExists(source);

    if (!dirExists) {
      log.error(`${source} is not a valid template directory`);

      return false;
    }

    const dirs = await readDir(source);

    if (!isEmpty(dirs)) {
      for (const file of dirs) {
        await this.scaffoldFile(path.join(source, file), type);
      }
    }

    return true;
  }

  /**
   * Renders a specific template file.
   */
  static async scaffoldFile(
    source: string,
    type: string = 'project',
  ): Promise<boolean> {
    let file = path.basename(source, '.mustache');

    // Templates for hidden files start with `_` instead of `.`
    if (file.startsWith('_')) {
      file = file.replace('_', '.');
    }

    log.message(`Checking for ${file}...`);

    const base = await this.getBasePath(type);
    const dest = path.join(base, file);

    const templateFileExists = await fileExists(dest);

    if (templateFileExists) {
      log.ok(`${file} exists.`);

      return true;
    }

    await fs.mkdirp(base);

    const templateData = await getConfig();

    try {
      const fileBuffer = await fs.readFile(source);
      const templateContent = fileBuffer.toString();
      const renderedContent = mustache.render(templateContent, templateData);

      await fs.writeFile(dest, renderedContent);

      log.ok(`${file} created.`);
    } catch (error) {
      if (!isEmpty(error)) {
        log.error(error);

        return false;
      }
    }

    return true;
  }
}

export default mock(Scaffold);