ember-cli/ember-twiddle

View on GitHub
app/services/ember-cli.js

Summary

Maintainability
D
2 days
Test Coverage
import { readOnly } from '@ember/object/computed';
import Service, { inject as service } from '@ember/service';
import RSVP from 'rsvp';
import { run } from '@ember/runloop';
import $ from 'jquery';
import Babel from '@babel/core';
import Path from 'path';
import HbsPlugin from '../plugins/hbs-plugin';
import NewModulesPlugin from 'babel-plugin-ember-modules-api-polyfill';
import blueprints from '../lib/blueprints';
import Ember from 'ember';
import moment from 'moment';
import _template from "lodash/template";
import md5 from 'blueimp-md5';
import { pushDeletion } from 'ember-twiddle/utils/push-deletion';

const twiddleAppName = 'twiddle';
const oldTwiddleAppNames = ['demo-app', 'app'];
const hbsPlugin = new HbsPlugin(Babel);
const newModulesPlugin = new NewModulesPlugin(Babel);

// These files will be included if not present
const boilerPlateJs = [
  'app',
  'router',
  'initializers/router',
  'initializers/mouse-events',
  'resolver'
];

// These files have to be present
const requiredFiles = [
  'twiddle.json'
];

const availableBlueprints = {
  'templates/application': {
    blueprint: 'templates/application',
    filePath: 'templates/application.hbs',
    podFilePath: 'application/templates.hbs'
  },
  'controllers/application': {
    blueprint: 'controllers/application',
    filePath: 'controllers/application.js',
    podFilePath: 'application/controller.js'
  },
  'app': {
    blueprint: 'app',
    filePath: 'app.js'
  },
  'css': {
    blueprint: 'app.css',
    filePath: 'styles/app.css'
  },
  'component-hbs': {
    blueprint: 'component-hbs',
    filePath: 'templates/components/my-component.hbs',
    podFilePath: 'my-component/template.hbs'
  },
  'component-js': {
    blueprint: 'component-js',
    filePath: 'components/my-component.js',
    podFilePath: 'my-component/component.js'
  },
  'controller': {
    blueprint: 'controller',
    filePath: 'controllers/my-route.js',
    podFilePath: 'my-route/controller.js'
  },
  'initializers/router': {
    blueprint: 'initializers/router',
    filePath: 'initializers/router.js'
  },
  'initializers/mouse-events': {
    blueprint: 'initializers/mouse-events',
    filePath: 'initializers/mouse-events'
  },
  'model': {
    blueprint: 'model',
    filePath: 'models/my-model.js'
  },
  'helper': {
    blueprint: 'helper',
    filePath: 'helpers/my-helper.js'
  },
  'route': {
    blueprint: 'route',
    filePath: 'routes/my-route.js',
    podFilePath: 'my-route/route.js'
  },
  'service': {
    blueprint: 'service',
    filePath: 'services/my-service.js'
  },
  'template': {
    blueprint: 'template',
    filePath: 'templates/my-route.hbs',
    podFilePath: 'my-route/template.hbs'
  },
  'router': {
    blueprint: 'router',
    filePath: 'router.js'
  },
  'twiddle.json': {
    blueprint: 'twiddle.json',
    filePath: 'twiddle.json'
  },
  'resolver': {
    blueprint: 'resolver',
    filePath: 'resolver.js'
  },
  'test-helper': {
    blueprint: 'test-helper',
    filePath: 'tests/test-helper.js'
  },
  'controller-test': {
    blueprint: 'controller-test',
    filePath: 'tests/unit/controllers/my-controller-test.js'
  },
  'route-test': {
    blueprint: 'route-test',
    filePath: 'tests/unit/routes/my-route-test.js'
  },
  'service-test': {
    blueprint: 'service-test',
    filePath: 'tests/unit/services/my-service-test.js'
  },
  'component-test': {
    blueprint: 'component-test',
    filePath: 'tests/integration/components/my-component-test.js'
  },
  'acceptance-test': {
    blueprint: 'acceptance-test',
    filePath: 'tests/acceptance/my-acceptance-test.js'
  }
};

/**
 * A tiny browser version of the CLI build chain.
 * or more realistically: a hacked reconstruction of it.
 *
 * Parts of this module are directly copied from the ember-cli
 * source code at https://github.com/ember-cli/ember-cli
 */
export default Service.extend({
  store: service(),
  twiddleJson: service(),

  usePods: readOnly('twiddleJson.usePods'),
  enableTesting: false,

  setup(gist) {
    this.twiddleJson.setup(gist);
  },

  generate(type) {
    let store = this.store;
    run(() => pushDeletion(store, 'gist-file', type));
    return store.createRecord('gistFile', this.buildProperties(type));
  },

  buildProperties(type, replacements) {
    if (type in availableBlueprints) {
      let blueprint = availableBlueprints[type];
      let content = blueprints[blueprint.blueprint];

      if (replacements) {
        content = _template(content)(replacements);
      }

      return {
        filePath: this.usePods ? blueprint.podFilePath || blueprint.filePath : blueprint.filePath,
        content: content.replace(/<%=(.*)%>/gi,'')
      };
    }
  },

  nameWithModule(filePath) {
    // Remove app prefix if present
    let name = filePath.replace(/^app\//, '');

    let moduleName = Path.join(twiddleAppName,
      Path.relative('.', Path.dirname(name)),
      Path.basename(name, Path.extname(name)));

    return moduleName;
  },

  /**
   * Build a gist into an Ember app.
   *
   * @param  {Gist} gist    Gist to build
   * @return {Ember Object}       Source code for built Ember app
   */
  compileGist(gist) {
    let errors = [];
    let out = [];
    let cssOut = [];

    this.checkRequiredFiles(out, gist);

    let processedFiles = this.handleColocatedComponents(gist);

    processedFiles.forEach(file => {
      this.compileFile(file, errors, out, cssOut);
    });

    if (errors.length) {
      return RSVP.reject(errors);
    }

    this.addBoilerPlateFiles(out, gist);

    this.deleteTempFiles(processedFiles);

    return this.twiddleJson.getTwiddleJson(gist)
      .then(twiddleJSON => {
        this.addConfig(out, gist, twiddleJSON);
        this.set('enableTesting', testingEnabled(twiddleJSON));

        // Add boot code
        contentForAppBoot(
          out,
          {
            modulePrefix: twiddleAppName,
            dependencies: twiddleJSON.dependencies,
            testingEnabled: testingEnabled(twiddleJSON),
            legacyTesting: legacyTesting(twiddleJSON)
          }
        );
        return this.buildHtml(gist, out.join('\n'), cssOut.join('\n'), twiddleJSON);
      });
  },

  handleColocatedComponents(gist) {
    let colocatedTemplatesRegex = /^components\/([^/]+\/)*[^/]+\.hbs$/

    let files = gist.get('files').toArray();
    let filePaths = files.map(file => file.get('filePath'));

    let newFiles = [...files];

    files.forEach(file => {
      let hbsFilePath = file.get('filePath');
      if (colocatedTemplatesRegex.test(hbsFilePath)) {
        let jsFilePath = hbsFilePath.substr(0, hbsFilePath.lastIndexOf('.')) + '.js';
        let hbsFileName = hbsFilePath.substring(hbsFilePath.lastIndexOf('/'), hbsFilePath.lastIndexOf('.'));
        if (filePaths.includes(jsFilePath)) {
          let jsFile = files.findBy('filePath', jsFilePath);
          let hbsFile = file;
          let jsFileName = jsFilePath.substring(jsFilePath.lastIndexOf('/'), jsFilePath.lastIndexOf('.'));
          let jsHash = md5(jsFilePath);
          let hbsHash = md5(hbsFilePath);
          let prefix = jsFilePath.substr(0, jsFilePath.lastIndexOf('/'));
          let newJsFilePath = prefix + '/' + jsHash + '.js';
          let newHbsFilePath = prefix + '/' + hbsHash + '.hbs';
          let newJsFile = this.store.createRecord('gist-file', {
            isTemp: true,
            filePath: newJsFilePath,
            content: jsFile.get('content')
          });
          let newHbsFile = this.store.createRecord('gist-file', {
            isTemp: true,
            filePath: newHbsFilePath,
            content: hbsFile.get('content')
          });
          newFiles.removeObject(jsFile);
          newFiles.addObject(newJsFile);
          newFiles.removeObject(hbsFile);
          newFiles.addObject(newHbsFile);

          let newEmittedFile;
          if (jsFileName === 'index') {
            let dirName = prefix.substr(prefix.lastIndexOf('/'));
            newEmittedFile = this.store.createRecord('gist-file', {
              isTemp: true,
              filePath: prefix + '.js',
              content: `
                import Component from './${dirName}/${jsHash}';
                import Template from './${dirName}/${hbsHash}';
                export * from './${jsHash}';
                export default Ember._setComponentTemplate(Template, Component);
              `
            });
          } else {
            newEmittedFile = this.store.createRecord('gist-file', {
              isTemp: true,
              filePath: prefix + '/' + jsFileName + '.js',
              content: `
                import Component from './${jsHash}';
                import Template from './${hbsHash}';
                export * from './${jsHash}';
                export default Ember._setComponentTemplate(Template, Component);
              `
            });
          }
          newFiles.addObject(newEmittedFile);
        } else {
          if (hbsFileName === 'index') {
            // TODO: handle index file
          }
          let hbsFile = file;
          let prefix = hbsFilePath.substr(0, hbsFilePath.lastIndexOf('/'));
          let hbsHash = md5(hbsFilePath);
          let newHbsFilePath = prefix + '/' + hbsHash + '.hbs';
          let newHbsFile = this.store.createRecord('gist-file', {
            isTemp: true,
            filePath: newHbsFilePath,
            content: hbsFile.get('content')
          });
          newFiles.removeObject(hbsFile);
          newFiles.addObject(newHbsFile);
          let newEmittedFile;
          if (hbsFileName === 'index') {
            let dirName = prefix.substr(prefix.lastIndexOf('/'));
            newEmittedFile = this.store.createRecord('gist-file', {
              isTemp: true,
              filePath: prefix + '.js',
              content: `
                import Template from './${dirName}/${hbsHash}';
                const Component = Ember._templateOnlyComponent("${prefix + '/' + hbsHash}");
                export default Ember._setComponentTemplate(Template, Component);
              `
            });
          } else {
            newEmittedFile = this.store.createRecord('gist-file', {
              isTemp: true,
              filePath: prefix + '/' + hbsFileName + '.js',
              content: `
                import Template from './${hbsHash}';
                const Component = Ember._templateOnlyComponent("${prefix + '/' + hbsHash}");
                export default Ember._setComponentTemplate(Template, Component);
              `
            });
          }
          newFiles.addObject(newEmittedFile);
        }
      }
    });
    return newFiles;
  },

  compileFile(file, errors, out, cssOut) {
    const content = file.get('content');
    const filePath = file.get('filePath');

    try {
      switch(file.get('extension')) {
        case '.js':
          out.push(this.compileJs(content, filePath));
          break;
        case '.hbs':
          out.push(this.compileHbs(content, filePath));
          break;
        case '.css':
          cssOut.push(this.compileCss(content, filePath));
          break;
        case '.json':
          break;
      }
    }
    catch(e) {
      e.message = `${file.get('filePath')}: ${e.message}`;
      errors.push(e);
    }
  },

  buildHtml(gist, appJS, appCSS, twiddleJSON) {
    if (gist.get('initialRoute')) {
      appJS += "window.location.hash='" + gist.get('initialRoute') + "';";
    }

    // avoids security error
    appJS += "window.history.pushState = function() {}; window.history.replaceState = function() {}; window.sessionStorage = undefined;";

    // Hide toolbar since it is not working
    appCSS += `\n#qunit-testrunner-toolbar, #qunit-tests a[href] { display: none; }\n`;

    let index = blueprints['index.html'];

    let { depScriptTags, depCssLinkTags, testStuff } = this.buildDependencies(twiddleJSON);

    let appScriptTag = `<script type="text/javascript">${appJS}</script>`;
    let appStyleTag = `<style type="text/css">${appCSS}</style>`;

    index = index.replace('{{content-for \'head\'}}', `${depCssLinkTags}\n${appStyleTag}`);

    let contentForBody = `${depScriptTags}\n${appScriptTag}\n${testStuff}\n`;

    contentForBody += '<div id="root"></div>';

    index = index.replace('{{content-for \'body\'}}', contentForBody);

    // replace the {{build-timestamp}} placeholder with the number of
    // milliseconds since the Unix Epoch:
    // http://momentjs.com/docs/#/displaying/unix-offset/
    index = index.replace('{{build-timestamp}}', +moment());

    return index;
  },

  buildDependencies(twiddleJSON) {
    let deps = twiddleJSON.dependencies;
    let depCssLinkTags = '';
    let depScriptTags = '';
    let testStuff = '';

    let EmberENV = twiddleJSON.EmberENV || {};
    const isTestingEnabled = testingEnabled(twiddleJSON);

    if (Ember.testing && !isTestingEnabled) {
      depScriptTags += `<script type="text/javascript" src="https://code.jquery.com/qunit/qunit-2.6.1.js"></script>`;
      depScriptTags += `<script type="text/javascript">QUnit.config.autostart = false;</script>`;
    }

    depScriptTags += `<script type="text/javascript">EmberENV = ${JSON.stringify(EmberENV)};</script>`;
    depScriptTags += `<script type="text/javascript" src="${window.assetMap.loader}"></script>`;

    Object.keys(deps).forEach(function(depKey) {
      let dep = deps[depKey];
      let extension = dep.substr(dep.lastIndexOf("."));
      extension = extension.split("?")[0];
      if (extension === '.css') {
        depCssLinkTags += `<link rel="stylesheet" type="text/css" href="${dep}">`;
      } else if (extension === '.js') {
        depScriptTags += `<script type="text/javascript" src="${dep}"></script>`;
      } else {
        // eslint-disable-next-line no-console
        console.warn("Could not determine extension of " + dep);
      }
    });

    if (Ember.testing && isTestingEnabled) {
      testStuff += `
        <script type="text/javascript">
          // Hack around dealing with multiple global QUnits!
          jQuery.ajax({
            url: 'https://code.jquery.com/qunit/qunit-2.6.1.js',
            dataType: 'text'
          }).then(function(script) {
            Ember.run(function() {
              var oldQUnit;
              if (window.QUnit) {
                oldQUnit = window.QUnit;
              }
              window.QUnit = {
                config: {
                  autostart: false
                }
              }
              eval(script);
              if (!oldQUnit) {
                oldQUnit = window.QUnit;
              }
              if (window.testModule) {
                window.require(window.testModule);
              }
              window.QUnit.start = function() {};
              window.QUnit.done(function() {
                window.QUnit = oldQUnit;
              });
              oldQUnit.start();
            });
          });
        </script>`;
    }

    depScriptTags += `<script type="text/javascript" src="${window.assetMap.twiddleDeps}"></script>`;

    if (isTestingEnabled) {
      const testJSFiles = ['testLoader', 'testSupport'];

      testJSFiles.forEach(jsFile => {
        depScriptTags += `<script type="text/javascript" src="${window.assetMap[jsFile]}"></script>`;
      });

      depCssLinkTags += `<link rel="stylesheet" type="text/css" href="${window.assetMap.testSupportCss}">`;

      testStuff += `
        <div id="qunit"></div>
        <div id="qunit-fixture"></div>
        <div id="ember-testing-container">
          <div id="ember-testing"></div>
          <div id="test-root"></div>
        </div>`;

      let moreCode = "window.requirejs.entries['ember-cli/test-loader'] = window.requirejs.entries['ember-cli-test-loader/test-support/index'] || requirejs.entries['assets/test-loader'] || window.requirejs.entries['ember-cli/test-loader'];\n";
      testStuff += `<script type="text/javascript">${moreCode}window.require("${twiddleAppName}/tests/test-helper");</script>`;
    }

    if (Ember.testing || isTestingEnabled) {
      const testJSFiles = ['emberQUnit'];

      testJSFiles.forEach(jsFile => {
        depScriptTags += `<script type="text/javascript" src="${window.assetMap[jsFile]}"></script>`;
      });

      testStuff += `<script type="text/javascript">
        Ember.Test.adapter = window.require('ember-qunit').QUnitAdapter.create();
      </script>`;
    }

    return { depScriptTags, depCssLinkTags, testStuff };
  },

  checkRequiredFiles(out, gist) {
    requiredFiles.forEach(filePath => {
      let file = gist.get('files').findBy('filePath', filePath);
      if (!file) {
        let store = this.store;
        run(() => pushDeletion(store, 'gist-file', filePath));
        gist.get('files').pushObject(store.createRecord('gistFile', {
          filePath: filePath,
          content: blueprints[filePath]
        }));
      }
    });
  },

  addBoilerPlateFiles(out, gist) {
    boilerPlateJs.forEach(blueprintName => {
      let blueprint = availableBlueprints[blueprintName];
      if(!gist.get('files').findBy('filePath', blueprint.filePath)) {
        out.push(this.compileJs(blueprints[blueprint.blueprint], blueprint.filePath));
      }
    });
  },

  addConfig(out, gist, twiddleJson) {
    let config = {
      modulePrefix: twiddleAppName,
      TWIDDLE_ORIGIN: location.origin
    };

    config = $.extend((twiddleJson.ENV || {}), config);

    let configJs = 'export default ' + JSON.stringify(config);
    out.push(this.compileJs(configJs, 'config/environment'));
  },

  /**
   * Compile a javascript file. This means that we
   * transform it using Babel.
   *
   * @param  {String} code       ES6 module code
   * @param  {String} filePath   File path (will be used for module name)
   * @return {String}            Transpiled module code
   */
  compileJs(code, filePath) {
    code = this.fixTwiddleAppNames(code);
    let moduleName = this.nameWithModule(filePath);
    let output = Babel.transform(code, babelOpts(moduleName)).code;
    return output;
  },

  /**
   * Compile a Handlebars template into an AMD module.
   *
   * @param  {String} code       hbs code
   * @param  {String} filePath   File path (will be used for module name)
   * @return {String}            AMD module code
   */
  compileHbs(code, filePath) {

    // Compiles all templates at runtime.
    let moduleName = this.nameWithModule(filePath);

    const mungedCode = (code || '')
      .replace(/\\/g, "\\\\") // Prevent backslashes from being escaped
      .replace(/`/g, "\\`") // Prevent backticks from causing syntax errors
      .replace(/\$/g, "\\$"); // Allow ${} expressions in the code

    return this.compileJs('export default Ember.HTMLBars.compile(`' + mungedCode + '`, { moduleName: `' + moduleName + '`});', filePath);
  },

  compileCss(code, moduleName) {
    var prefix = "styles/";
    if (moduleName.substring(0, prefix.length) === prefix) {
      return code;
    }
    return '';
  },

  updateDependencyVersion(gist, dependencyName, version) {
    return this.twiddleJson.updateDependencyVersion(gist, dependencyName, version);
  },

  ensureTestingEnabled(gist) {
    return this.twiddleJson.ensureTestingEnabled(gist);
  },

  // For backwards compatibility with old names for the twiddle app
  fixTwiddleAppNames(code) {
    oldTwiddleAppNames.forEach((oldName) => {
      code = code.replace(new RegExp(
        `import\\ ([^]+?)\\ from\\ ([\\'\\"])${oldName}\\/`, 'g'),
        "import $1 from $2twiddle/");
    });
    return code;
  },

  setTesting(gist, enabled = true) {
    this.twiddleJson.setTesting(gist, enabled);
  },

  deleteTempFiles(files) {
    files.forEach(file => {
      if (file.isTemp) {
        run(() => pushDeletion(this.store, 'gist-file', file.get('fileName')));
      }
    });
  }
});

/**
 * Generate babel options for the specified module
 * @param  {String} moduleName
 * @return {Object}            Babel options
 */
function babelOpts(moduleName) {

  return {
    presets: [['env', {
      targets: {
        browsers: [
          'last 2 chrome versions',
          'last 2 firefox versions',
          'last 2 safari versions',
          'last 2 edge versions'
        ]
      }
    }]],
    moduleIds: true,
    moduleId: moduleName,
    plugins: [
      ['transform-modules-amd', {
        loose: true,
        noInterop: true
      }],
      ['proposal-decorators', {
        legacy: true
      }],
      'proposal-class-properties',
      'proposal-object-rest-spread',
      hbsPlugin,
      newModulesPlugin,
    ]
  };
}

/**
 * Generate the application boot code
 * @param  {Array} content  Code buffer to append to
 * @param  {Object} config  App configuration
 * @return {Array}          Code buffer
 */
function contentForAppBoot(content, config) {

  // Some modules are not actually transpiled so Babel
  // doesn't recognize them properly...
  var monkeyPatchModules = [
    'ember',
    'ember-load-initializers',
    'ember-resolver'
  ];

  if ("ember-data" in config.dependencies) {
    monkeyPatchModules.push('ember-data');
  }

  monkeyPatchModules.forEach(function(mod) {
    content.push('  window.require("'+mod+'").__esModule=true;');
  });

  if (!config.testingEnabled || config.legacyTesting) {
    content.push('  window.require("' +
      config.modulePrefix +
      '/app")["default"].create(' +
      calculateAppConfig(config) +
      ');');
  }
}

/**
 * Directly copied from ember-cli
 */
function calculateAppConfig(config) {
  let appConfig = config.APP || {};
  appConfig.rootElement="#main";
  return JSON.stringify(appConfig);
}

function testingEnabled(twiddleJSON) {
  return twiddleJSON && twiddleJSON.options && twiddleJSON.options["enable-testing"];
}

function legacyTesting(twiddleJSON) {
  return twiddleJSON && twiddleJSON.options && twiddleJSON.options["legacy-testing"];
}