Skip to content

Commit

Permalink
Merge pull request #153 from salsify/colocation
Browse files Browse the repository at this point in the history
[Experimental] Support template co-location and Embroider
  • Loading branch information
dfreeman authored Oct 2, 2019
2 parents bda04c8 + 251a2d9 commit 48ac3e5
Show file tree
Hide file tree
Showing 11 changed files with 274 additions and 94 deletions.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ For example, the component given above in pod structure would look like this in

Similarly, if you were styling e.g. your application controller, you would mirror the template at `app/templates/application.hbs` and put your CSS at `app/styles/application.css`.

### Component Colocation in Octane Applications

In Octane apps, where component templates can be colocated with their backing class, your styles module for a component takes the same name as the backing class and template files:

```hbs
{{! app/components/my-component.hbs }}
<div local-class="hello-class">Hello, world!</div>
```

```css
/* app/components/my-component.css */
.hello-class {
font-weight: bold;
}
```

### Styling Reuse

In the example above, `hello-class` is rewritten internally to something like `_hello-class_1dr4n4` to ensure it doesn't conflict with a `hello-class` defined in some other module.
Expand Down Expand Up @@ -141,6 +157,14 @@ console.log(styles['hello-class']);
// => "_hello-class_1dr4n4"
```

**Note**: by default, the import path for a styles module does _not_ include the `.css` (or equivalent) extension. However, if you set `includeExtensionInModulePath: true`, then you'd instead write:

```js
import styles from 'my-app-name/components/my-component/styles.css';
```

Note that the extension is **always** included for styles modules that are part of an Octane "colocated" component, to avoid a conflict with the import path for the component itself.

### Applying Classes to a Component's Root Element

There is no root element, if you are using either of the following:
Expand Down Expand Up @@ -387,6 +411,20 @@ module.exports = {
};
```

### Extensions in Module Paths

When importing a CSS module's values from JS, or referencing it via `@value` or `composes:`, by default you do not include the `.css` extension in the import path. The exception to this rule is for modules that are part of an Octane-style colocated component, as the extension is the only thing to differentiate the styles module from the component module itself.

If you wish to enable this behavior for _all_ modules, you can set the `includeExtensionInModulePath` flag in your configuration:

```js
new EmberApp(defaults, {
cssModules: {
includeExtensionInModulePath: true,
},
});
```

### Scoped Name Generation

By default, ember-css-modules produces a unique scoped name for each class in a module by combining the original class name with a hash of the path of the containing module. You can override this behavior by passing a `generateScopedName` function in the configuration.
Expand Down
29 changes: 22 additions & 7 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,11 @@ module.exports = {
this.modulesPreprocessor = new ModulesPreprocessor({ owner: this });
this.outputStylesPreprocessor = new OutputStylesPreprocessor({ owner: this });
this.checker = new VersionChecker(this.project);
this.plugins = new PluginRegistry(this.parent);
},

included(includer) {
debug('included in %s', includer.name);
this.ownerName = includer.name;
this.plugins = new PluginRegistry(this.parent);
this.cssModulesOptions = this.plugins.computeOptions(includer.options && includer.options.cssModules);

if (this.belongsToAddon()) {
this.verifyStylesDirectory();
Expand Down Expand Up @@ -58,15 +56,24 @@ module.exports = {
// Skip if we're setting up this addon's own registry
if (type !== 'parent') { return; }

let includerOptions = this.app ? this.app.options : this.parent.options;
this.cssModulesOptions = this.plugins.computeOptions(includerOptions && includerOptions.cssModules);

registry.add('js', this.modulesPreprocessor);
registry.add('css', this.outputStylesPreprocessor);
registry.add('htmlbars-ast-plugin', HtmlbarsPlugin.forEmberVersion(this.checker.forEmber().version));
registry.add('htmlbars-ast-plugin', HtmlbarsPlugin.instantiate({
emberVersion: this.checker.forEmber().version,
options: {
fileExtension: this.getFileExtension(),
includeExtensionInModulePath: this.includeExtensionInModulePath(),
},
}));
},

verifyStylesDirectory() {
if (!fs.existsSync(path.join(this.parent.root, this.parent.treePaths['addon-styles']))) {
this.ui.writeWarnLine(
'The addon ' + this.getOwnerName() + ' has ember-css-modules installed, but no addon styles directory. ' +
'The addon ' + this.getParentName() + ' has ember-css-modules installed, but no addon styles directory. ' +
'You must have at least a placeholder file in this directory (e.g. `addon/styles/.placeholder`) in ' +
'the published addon in order for ember-cli to process its CSS modules.'
);
Expand All @@ -77,8 +84,8 @@ module.exports = {
this.plugins.notify(event);
},

getOwnerName() {
return this.ownerName;
getParentName() {
return this.app ? this.app.name : this.parent.name;
},

getParent() {
Expand All @@ -97,6 +104,10 @@ module.exports = {
return this.cssModulesOptions.generateScopedName || require('./lib/generate-scoped-name');
},

getModuleRelativePath(fullPath) {
return this.modulesPreprocessor.getModuleRelativePath(fullPath);
},

getModulesTree() {
return this.modulesPreprocessor.getModulesTree();
},
Expand All @@ -121,6 +132,10 @@ module.exports = {
return this.cssModulesOptions && this.cssModulesOptions.extension || 'css';
},

includeExtensionInModulePath() {
return !!this.cssModulesOptions.includeExtensionInModulePath;
},

getPostcssOptions() {
return this.cssModulesOptions.postcssOptions;
},
Expand Down
57 changes: 37 additions & 20 deletions lib/htmlbars-plugin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,54 @@ const utils = require('./utils');
const semver = require('semver');

module.exports = class ClassTransformPlugin {
constructor(options) {
this.syntax = options.syntax;
this.builders = options.syntax.builders;
this.stylesModule = this.determineStylesModule(options.meta);
constructor(env, options) {
this.syntax = env.syntax;
this.builders = env.syntax.builders;
this.options = options;
this.stylesModule = this.determineStylesModule(env);
this.isGlimmer = this.detectGlimmer();
this.visitor = this.buildVisitor();
this.visitor = this.buildVisitor(env);

// Alias for 2.15 <= Ember < 3.1
this.visitors = this.visitor;
}

static forEmberVersion(version) {
static instantiate({ emberVersion, options }) {
return {
name: 'ember-css-modules',
plugin: semver.lt(version, '2.15.0-alpha')
? LegacyAdapter.bind(null, this)
: options => new this(options),
plugin: semver.lt(emberVersion, '2.15.0-alpha')
? LegacyAdapter.bind(null, this, options)
: env => new this(env, options),
parallelBabel: {
requireFile: __filename,
buildUsing: 'forEmberVersion',
params: version
buildUsing: 'instantiate',
params: { emberVersion, options },
},
baseDir() {
return `${__dirname}/../..`;
}
};
}

determineStylesModule(meta) {
if (!meta || !meta.moduleName) return;
determineStylesModule(env) {
if (!env || !env.moduleName) return;

let includeExtension = this.options.includeExtensionInModulePath;
let name = env.moduleName.replace(/\.\w+$/, '');

let name = meta.moduleName.replace(/\.\w+$/, '');
if (name.endsWith('template')) {
return name.replace(/template$/, 'styles');
name = name.replace(/template$/, 'styles');
} else if (name.includes('/templates/')) {
return name.replace('/templates/', '/styles/');
name = name.replace('/templates/', '/styles/');
} else if (name.includes('/components/')) {
includeExtension = true;
}

if (includeExtension) {
name = `${name}.${this.options.fileExtension}`;
}

return name;
}

detectGlimmer() {
Expand All @@ -52,7 +63,12 @@ module.exports = class ClassTransformPlugin {
return ast.body[0].attributes[0].value.parts[0].type === 'TextNode';
}

buildVisitor() {
buildVisitor(env) {
if (env.moduleName === env.filename) {
// No-op for the stage 1 Embroider pass (which only contains relative paths)
return {};
}

return {
ElementNode: node => this.transformElementNode(node),
MustacheStatement: node => this.transformStatement(node),
Expand Down Expand Up @@ -212,14 +228,15 @@ module.exports = class ClassTransformPlugin {

// For Ember < 2.15
class LegacyAdapter {
constructor(plugin, options) {
constructor(plugin, options, env) {
this.plugin = plugin;
this.meta = options.meta;
this.options = options;
this.meta = env.meta;
this.syntax = null;
}

transform(ast) {
let plugin = new this.plugin({ meta: this.meta, syntax: this.syntax });
let plugin = new this.plugin(Object.assign({ syntax: this.syntax }, this.meta), this.options);
this.syntax.traverse(ast, plugin.visitor);
return ast;
}
Expand Down
89 changes: 80 additions & 9 deletions lib/modules-preprocessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,48 @@

const Funnel = require('broccoli-funnel');
const MergeTrees = require('broccoli-merge-trees');
const Bridge = require('broccoli-bridge');
const ensurePosixPath = require('ensure-posix-path');
const normalizePostcssPlugins = require('./utils/normalize-postcss-plugins');
const debug = require('debug')('ember-css-modules:modules-preprocessor');
const fs = require('fs');

module.exports = class ModulesPreprocessor {
constructor(options) {
this.name = 'ember-css-modules';
this.owner = options.owner;
this._modulesTree = null;
this._modulesBasePath = null;
this._modulesBridge = new Bridge();
}

toTree(inputTree, path) {
if (path !== '/') { return inputTree; }

let merged = new MergeTrees([inputTree, this.getModulesTree()], { overwrite: true });
let merged = new MergeTrees([inputTree, this.buildModulesTree(inputTree)], { overwrite: true });

// Exclude the individual CSS files – those will be concatenated into the styles tree later
return new Funnel(merged, { exclude: ['**/*.' + this.owner.getFileExtension()] });
}

getModulesTree() {
buildModulesTree(modulesInput) {
if (!this._modulesTree) {
let inputRoot = this.owner.belongsToAddon() ? this.owner.getParentAddonTree() : this.owner.app.trees.app;
let outputRoot = (this.owner.belongsToAddon() ? this.owner.getAddonModulesRoot() : '');

let modulesSources = new Funnel(inputRoot, {
if (outputRoot) {
inputRoot = new Funnel(inputRoot, {
destDir: outputRoot,
});
}

let modulesSources = new ModuleSourceFunnel(inputRoot, modulesInput, {
include: ['**/*.' + this.owner.getFileExtension()],
destDir: outputRoot + this.owner.getOwnerName(),
outputRoot,
parentName: this.owner.getParentName()
});

this._modulesTree = new (require('broccoli-css-modules'))(modulesSources, {
let modulesTree = new (require('broccoli-css-modules'))(modulesSources, {
extension: this.owner.getFileExtension(),
plugins: this.getPostcssPlugins(),
enableSourceMaps: this.owner.enableSourceMaps(),
Expand All @@ -40,6 +52,7 @@ module.exports = class ModulesPreprocessor {
virtualModules: this.owner.getVirtualModules(),
generateScopedName: this.scopedNameGenerator(),
resolvePath: this.resolveAndRecordPath.bind(this),
getJSFilePath: cssPath => this.getJSFilePath(cssPath, modulesSources),
onBuildStart: () => this.owner.notifyPlugins('buildStart'),
onBuildEnd: () => this.owner.notifyPlugins('buildEnd'),
onBuildSuccess: () => this.owner.notifyPlugins('buildSuccess'),
Expand All @@ -49,9 +62,39 @@ module.exports = class ModulesPreprocessor {
onImportResolutionFailure: this.onImportResolutionFailure.bind(this),
formatJS: formatJS
});

this._modulesTree = modulesTree;
this._modulesBridge.fulfill('modules', modulesTree);
}

return this._modulesTree;
return this.getModulesTree();
}

getModulesTree() {
return this._modulesBridge.placeholderFor('modules');
}

getModuleRelativePath(fullPath) {
if (!this._modulesBasePath) {
this._modulesBasePath = ensurePosixPath(this._modulesTree.inputPaths[0]);
}

return fullPath.replace(this._modulesBasePath + '/', '');
}

getJSFilePath(cssPathWithExtension, modulesSource) {
if (this.owner.includeExtensionInModulePath()) {
return `${cssPathWithExtension}.js`;
}

let extensionRegex = new RegExp(`\\.${this.owner.getFileExtension()}$`);
let cssPathWithoutExtension = cssPathWithExtension.replace(extensionRegex, '');

if (modulesSource.has(`${cssPathWithoutExtension}.hbs`)) {
return `${cssPathWithExtension}.js`;
} else {
return `${cssPathWithoutExtension}.js`;
}
}

scopedNameGenerator() {
Expand Down Expand Up @@ -132,7 +175,7 @@ module.exports = class ModulesPreprocessor {

rootPathPlugin() {
return require('postcss').plugin('root-path-tag', () => (css) => {
css.source.input.rootPath = this.getModulesTree().inputPaths[0];
css.source.input.rootPath = this._modulesTree.inputPaths[0];
});
}

Expand All @@ -141,9 +184,9 @@ module.exports = class ModulesPreprocessor {

return this._resolvePath(importPath, fromFile, {
defaultExtension: this.owner.getFileExtension(),
ownerName: this.owner.getOwnerName(),
ownerName: this.owner.getParentName(),
addonModulesRoot: this.owner.getAddonModulesRoot(),
root: ensurePosixPath(this.getModulesTree().inputPaths[0]),
root: ensurePosixPath(this._modulesTree.inputPaths[0]),
parent: this.owner.getParent()
});
}
Expand All @@ -155,3 +198,31 @@ const EXPORT_POST = ';\n';
function formatJS(classMapping) {
return EXPORT_PRE + JSON.stringify(classMapping, null, 2) + EXPORT_POST;
}

class ModuleSourceFunnel extends Funnel {
constructor(input, stylesTree, options) {
super(input, options);
this.stylesTree = stylesTree;
this.parentName = options.parentName;
this.destDir = options.outputRoot;
this.inputHasParentName = null;
}

has(filePath) {
let relativePath = this.inputHasParentName ? filePath : filePath.replace(`${this.parentName}/`, '');
return fs.existsSync(`${this.inputPaths[0]}/${relativePath}`);
}

build() {
if (this.inputHasParentName === null) {
this.inputHasParentName = fs.existsSync(`${this.inputPaths[0]}/${this.parentName}`);

let stylesTreeHasParentName = fs.existsSync(`${this.stylesTree.outputPath}/${this.parentName}`);
if (stylesTreeHasParentName && !this.inputHasParentName) {
this.destDir += `/${this.parentName}`;
}
}

return super.build(...arguments);
}
}
Loading

0 comments on commit 48ac3e5

Please sign in to comment.