app/services/ember-cli.js
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"];
}