Skip to content

Commit

Permalink
Initial implementation of co-located templates RFC. (#249)
Browse files Browse the repository at this point in the history
Initial implementation of co-located templates RFC.
  • Loading branch information
rwjblue committed Sep 24, 2019
2 parents d00bc8c + 35b66bf commit 6acc791
Show file tree
Hide file tree
Showing 12 changed files with 611 additions and 24 deletions.
7 changes: 4 additions & 3 deletions .eslintrc.js
@@ -1,7 +1,7 @@
module.exports = {
root: true,
parserOptions: {
ecmaVersion: 2017,
ecmaVersion: 2018,
sourceType: 'module',
},
plugins: ['ember', 'prettier'],
Expand All @@ -17,6 +17,7 @@ module.exports = {
'.eslintrc.js',
'.prettierrc.js',
'.template-lintrc.js',
'colocated-broccoli-plugin.js',
'ember-cli-build.js',
'lib/**/*.js',
'testem.js',
Expand All @@ -27,7 +28,7 @@ module.exports = {
excludedFiles: ['addon/**', 'addon-test-support/**', 'app/**', 'tests/dummy/app/**'],
parserOptions: {
sourceType: 'script',
ecmaVersion: 2015,
ecmaVersion: 2018,
},
env: {
browser: false,
Expand All @@ -44,7 +45,7 @@ module.exports = {
files: ['node-tests/**/*.js'],
parserOptions: {
sourceType: 'script',
ecmaVersion: 2015,
ecmaVersion: 2018,
},
env: {
browser: false,
Expand Down
61 changes: 61 additions & 0 deletions lib/colocated-babel-plugin.js
@@ -0,0 +1,61 @@
// For ease of debuggin / tweaking:
// https://astexplorer.net/#/gist/bcca584efdab6c981a75618642c76a22/1e1d262eaeb47b7da66150e0781a02b96e597b25
module.exports = function(babel) {
let t = babel.types;

return {
name: 'ember-cli-htmlbars-colocation-template',

visitor: {
VariableDeclarator(path, state) {
if (path.node.id.name === '__COLOCATED_TEMPLATE__') {
state.colocatedTemplateFound = true;
}
},

ExportDefaultDeclaration(path, state) {
if (!state.colocatedTemplateFound) {
return;
}

let defaultExportDeclaration = path.node.declaration;
let setComponentTemplateMemberExpression = t.memberExpression(
t.identifier('Ember'),
t.identifier('_setComponentTemplate')
);
let colocatedTemplateIdentifier = t.identifier('__COLOCATED_TEMPLATE__');

if (defaultExportDeclaration.type === 'ClassDeclaration') {
// when the default export is a ClassDeclaration with an `id`,
// wrapping it in a CallExpression would remove that class from the
// local scope which would cause issues for folks using the declared
// name _after_ the export
if (defaultExportDeclaration.id !== null) {
path.parent.body.push(
t.expressionStatement(
t.callExpression(setComponentTemplateMemberExpression, [
colocatedTemplateIdentifier,
defaultExportDeclaration.id,
])
)
);
} else {
path.node.declaration = t.callExpression(setComponentTemplateMemberExpression, [
colocatedTemplateIdentifier,
t.classExpression(
null,
defaultExportDeclaration.superClass,
defaultExportDeclaration.body
),
]);
}
} else {
path.node.declaration = t.callExpression(setComponentTemplateMemberExpression, [
colocatedTemplateIdentifier,
defaultExportDeclaration,
]);
}
},
},
};
};
121 changes: 121 additions & 0 deletions lib/colocated-broccoli-plugin.js
@@ -0,0 +1,121 @@
'use strict';

const fs = require('fs');
const mkdirp = require('mkdirp');
const copyFileSync = require('fs-copy-file-sync');
const path = require('path');
const walkSync = require('walk-sync');
const Plugin = require('broccoli-plugin');

function detectRootName(files) {
let [first] = files;
let parts = first.split('/');

let root;
if (parts[0].startsWith('@')) {
root = parts.slice(0, 2).join('/');
} else {
root = parts[0];
}

if (!files.every(f => f.startsWith(root))) {
root = null;
}

return root;
}

module.exports = class ColocatedTemplateProcessor extends Plugin {
constructor(tree, options) {
super([tree], options);
}

build() {
let files = walkSync(this.inputPaths[0], { directories: false });

let root = detectRootName(files);

let filesToCopy = [];
files.forEach(filePath => {
if (root === null) {
// do nothing, we cannot detect the proper root path for the app/addon
// being processed
filesToCopy.push(filePath);
return;
}

let filePathParts = path.parse(filePath);
let inputPath = path.join(this.inputPaths[0], filePath);

// TODO: why are these different?
// Apps: my-app/components/foo.hbs, my-app/templates/components/foo.hbs
// Addons: components/foo.js, templates/components/foo.hbs
//
// will be fixed by https://github.com/ember-cli/ember-cli/pull/8834

let isInsideComponentsFolder = filePath.startsWith(`${root}/components/`);

// copy forward non-hbs files
// TODO: don't copy .js files that will ultimately be overridden
if (!isInsideComponentsFolder || filePathParts.ext !== '.hbs') {
filesToCopy.push(filePath);
return;
}

// TODO: deal with alternate extensions (e.g. ts)
let possibleJSPath = path.join(filePathParts.dir, filePathParts.name + '.js');
let hasJSFile = fs.existsSync(path.join(this.inputPaths[0], possibleJSPath));

if (filePathParts.name === 'template') {
// TODO: maybe warn?
return;
}

let templateContents = fs.readFileSync(inputPath, { encoding: 'utf8' });
let jsContents = null;

// TODO: deal with hygiene?
let prefix = `import { hbs } from 'ember-cli-htmlbars';\nconst __COLOCATED_TEMPLATE__ = hbs\`${templateContents}\`;\n`;

if (hasJSFile) {
// add the template, call setComponentTemplate

jsContents = fs.readFileSync(path.join(this.inputPaths[0], possibleJSPath), {
encoding: 'utf8',
});

if (!jsContents.includes('export default')) {
let message = `\`${filePath}\` does not contain a \`default export\`. Did you forget to export the component class?`;
jsContents = `${jsContents}\nthrow new Error(${JSON.stringify(message)});`;
prefix = '';
}
} else {
// create JS file, use null component pattern

jsContents = `import templateOnly from '@ember/component/template-only';\n\nexport default templateOnly();\n`;
}

jsContents = prefix + jsContents;

let outputPath = path.join(this.outputPath, possibleJSPath);

// TODO: don't speculatively mkdirSync (likely do in a try/catch with ENOENT)
mkdirp.sync(path.dirname(outputPath));
fs.writeFileSync(outputPath, jsContents, { encoding: 'utf8' });
});

filesToCopy.forEach(filePath => {
let inputPath = path.join(this.inputPaths[0], filePath);
let outputPath = path.join(this.outputPath, filePath);

// avoid copying file over top of a previously written one
if (fs.existsSync(outputPath)) {
return;
}

// TODO: don't speculatively mkdirSync (likely do in a try/catch with ENOENT)
mkdirp.sync(path.dirname(outputPath));
copyFileSync(inputPath, outputPath);
});
}
};
72 changes: 64 additions & 8 deletions lib/ember-addon-main.js
Expand Up @@ -2,32 +2,79 @@

const path = require('path');
const utils = require('./utils');
const debugGenerator = require('heimdalljs-logger');
const logger = debugGenerator('ember-cli-htmlbars');
const logger = require('heimdalljs-logger')('ember-cli-htmlbars');
const hasEdition = require('@ember/edition-utils').has;

let registryInvocationCounter = 0;

module.exports = {
name: require('../package').name,

parentRegistry: null,

_shouldColocateTemplates() {
if (this._cachedShouldColocateTemplates) {
return this._cachedShouldColocateTemplates;
}

const semver = require('semver');

let babel = this.parent.addons.find(a => a.name === 'ember-cli-babel');
let hasBabel = babel !== undefined;
let babelVersion = hasBabel && babel.pkg.version;

// using this.project.emberCLIVersion() allows us to avoid issues when `npm
// link` is used; if this addon were linked and we did something like
// `require('ember-cli/package').version` we would get our own ember-cli
// version **not** the one in use currently
let emberCLIVersion = this.project.emberCLIVersion();

let hasValidBabelVersion = hasBabel && semver.gte(babelVersion, '7.11.0');
let hasValidEmberCLIVersion = semver.gte(emberCLIVersion, '3.12.0-beta.2');
let hasOctane = hasEdition('octane');

this._cachedShouldColocateTemplates =
hasOctane && hasValidBabelVersion && hasValidEmberCLIVersion;

return this._cachedShouldColocateTemplates;
},

setupPreprocessorRegistry(type, registry) {
// ensure that broccoli-ember-hbs-template-compiler is not processing hbs files
registry.remove('template', 'broccoli-ember-hbs-template-compiler');

// when this.parent === this.project, `this.parent.name` is a function 😭
let parentName = typeof this.parent.name === 'function' ? this.parent.name() : this.parent.name;

registry.add('template', {
name: 'ember-cli-htmlbars',
ext: 'hbs',
_addon: this,
toTree(tree) {
let debugTree = require('broccoli-debug').buildDebugCallback(
`ember-cli-htmlbars:${parentName}:tree-${registryInvocationCounter++}`
);

let shouldColocateTemplates = this._addon._shouldColocateTemplates();
let htmlbarsOptions = this._addon.htmlbarsOptions();
let TemplateCompiler = require('./template-compiler-plugin');
return new TemplateCompiler(tree, htmlbarsOptions);

let inputTree = debugTree(tree, '01-input');

if (shouldColocateTemplates) {
const ColocatedTemplateProcessor = require('./colocated-broccoli-plugin');

inputTree = debugTree(new ColocatedTemplateProcessor(inputTree), '02-colocated-output');
}

const TemplateCompiler = require('./template-compiler-plugin');
return debugTree(new TemplateCompiler(inputTree, htmlbarsOptions), '03-output');
},

precompile(string) {
precompile(string, options) {
let htmlbarsOptions = this._addon.htmlbarsOptions();
let templateCompiler = htmlbarsOptions.templateCompiler;
return utils.template(templateCompiler, string);

return utils.template(templateCompiler, string, options);
},
});

Expand All @@ -46,7 +93,7 @@ module.exports = {

// add the babel-plugin-htmlbars-inline-precompile to the list of plugins
// used by `ember-cli-babel` addon
if (!this._isBabelPluginRegistered(babelPlugins)) {
if (!this._isInlinePrecompileBabelPluginRegistered(babelPlugins)) {
let pluginWrappers = this.astPlugins();
let templateCompilerPath = this.templateCompilerPath();
let pluginInfo = utils.setupPlugins(pluginWrappers);
Expand Down Expand Up @@ -110,6 +157,15 @@ module.exports = {
babelPlugins.push(htmlBarsPlugin);
}
}

if (this._shouldColocateTemplates()) {
const { hasPlugin, addPlugin } = require('ember-cli-babel-plugin-helpers');
let colocatedPluginPath = require.resolve('./colocated-babel-plugin');

if (!hasPlugin(babelPlugins, colocatedPluginPath)) {
addPlugin(babelPlugins, colocatedPluginPath);
}
}
},

/**
Expand All @@ -119,7 +175,7 @@ module.exports = {
* For non parallel api, check the 'modules' to see if it contains the babel plugin
* @param {*} plugins
*/
_isBabelPluginRegistered(plugins) {
_isInlinePrecompileBabelPluginRegistered(plugins) {
return plugins.some(plugin => {
if (Array.isArray(plugin)) {
return plugin[0] === require.resolve('babel-plugin-htmlbars-inline-precompile');
Expand Down
8 changes: 4 additions & 4 deletions lib/utils.js
Expand Up @@ -2,7 +2,6 @@

const fs = require('fs');
const path = require('path');
const HTMLBarsInlinePrecompilePlugin = require.resolve('babel-plugin-htmlbars-inline-precompile');
const hashForDep = require('hash-for-dep');
const debugGenerator = require('heimdalljs-logger');
const logger = debugGenerator('ember-cli-htmlbars');
Expand Down Expand Up @@ -136,12 +135,13 @@ function setup(pluginInfo, options) {
precompile.baseDir = () => path.resolve(__dirname, '..');
precompile.cacheKey = () => cacheKey;

let precompileInlineHTMLBarsPlugin = [
HTMLBarsInlinePrecompilePlugin,
let plugin = [
require.resolve('babel-plugin-htmlbars-inline-precompile'),
{ precompile, modules: options.modules },
'ember-cli-htmlbars:inline-precompile',
];

return precompileInlineHTMLBarsPlugin;
return plugin;
}

function makeCacheKey(templateCompilerPath, pluginInfo, extra) {
Expand Down

0 comments on commit 6acc791

Please sign in to comment.