Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Experimental] Support template co-location and Embroider #153

Merged
merged 2 commits into from
Oct 2, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 */

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is app/components/my-component/index.css also supported?

.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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is potentially a breaking change.

We have an addon like this:

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

  createCssModulesPlugin(parent) {
    return new BreakpointsPlugin(parent, {
      breakpoints: this.breakpoints
    });
  },

  included() {
    this.breakpoints = require('./breakpoints.json');
  }
}

Prior to this change, included was called before createCssModulesPlugin. Now the order is changed.

For this simple case, the fix is easy:

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

  createCssModulesPlugin(parent) {
    return new BreakpointsPlugin(parent, {
      breakpoints: this.breakpoints
    });
  },

  init(...args) {
    this._super(...args);
    this.breakpoints = require('./breakpoints.json');
  }
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

☹️ My goal was for this overall release to be non-breaking, but I don't see a way around this timing change, because we now need access to the ECM options before we can set up the HTMLBars plugin. Technically we never made any promises about invocation order, but hiding behind that rules-lawyering feels pretty bad.

On the public registry, at least, I think you and I are the only two authors of packages with the ember-css-modules-plugin keyword, though of course there's no way to know about private packages. Do you have any sense of how much this is likely to impact the plugins you've written? I don't think any of mine (internal or public) are relying on it, but they're mostly fairly simple.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally and speaking for @ClarkSource I'm totally fine with this change. None of my public plugins break, and of all private ones this was the only one. 🎉

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Ok, we'll see if anyone else runs into this during the beta, and assuming they don't we'll plan to keep this in as a non-major change under the banner of "undefined behavior can't break"

},

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