Skip to content

Commit 48ac3e5

Browse files
authored
Merge pull request #153 from salsify/colocation
[Experimental] Support template co-location and Embroider
2 parents bda04c8 + 251a2d9 commit 48ac3e5

File tree

11 files changed

+274
-94
lines changed

11 files changed

+274
-94
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ For example, the component given above in pod structure would look like this in
7373

7474
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`.
7575

76+
### Component Colocation in Octane Applications
77+
78+
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:
79+
80+
```hbs
81+
{{! app/components/my-component.hbs }}
82+
<div local-class="hello-class">Hello, world!</div>
83+
```
84+
85+
```css
86+
/* app/components/my-component.css */
87+
.hello-class {
88+
font-weight: bold;
89+
}
90+
```
91+
7692
### Styling Reuse
7793

7894
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.
@@ -141,6 +157,14 @@ console.log(styles['hello-class']);
141157
// => "_hello-class_1dr4n4"
142158
```
143159

160+
**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:
161+
162+
```js
163+
import styles from 'my-app-name/components/my-component/styles.css';
164+
```
165+
166+
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.
167+
144168
### Applying Classes to a Component's Root Element
145169

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

414+
### Extensions in Module Paths
415+
416+
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.
417+
418+
If you wish to enable this behavior for _all_ modules, you can set the `includeExtensionInModulePath` flag in your configuration:
419+
420+
```js
421+
new EmberApp(defaults, {
422+
cssModules: {
423+
includeExtensionInModulePath: true,
424+
},
425+
});
426+
```
427+
390428
### Scoped Name Generation
391429

392430
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.

index.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,11 @@ module.exports = {
2424
this.modulesPreprocessor = new ModulesPreprocessor({ owner: this });
2525
this.outputStylesPreprocessor = new OutputStylesPreprocessor({ owner: this });
2626
this.checker = new VersionChecker(this.project);
27+
this.plugins = new PluginRegistry(this.parent);
2728
},
2829

2930
included(includer) {
3031
debug('included in %s', includer.name);
31-
this.ownerName = includer.name;
32-
this.plugins = new PluginRegistry(this.parent);
33-
this.cssModulesOptions = this.plugins.computeOptions(includer.options && includer.options.cssModules);
3432

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

59+
let includerOptions = this.app ? this.app.options : this.parent.options;
60+
this.cssModulesOptions = this.plugins.computeOptions(includerOptions && includerOptions.cssModules);
61+
6162
registry.add('js', this.modulesPreprocessor);
6263
registry.add('css', this.outputStylesPreprocessor);
63-
registry.add('htmlbars-ast-plugin', HtmlbarsPlugin.forEmberVersion(this.checker.forEmber().version));
64+
registry.add('htmlbars-ast-plugin', HtmlbarsPlugin.instantiate({
65+
emberVersion: this.checker.forEmber().version,
66+
options: {
67+
fileExtension: this.getFileExtension(),
68+
includeExtensionInModulePath: this.includeExtensionInModulePath(),
69+
},
70+
}));
6471
},
6572

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

80-
getOwnerName() {
81-
return this.ownerName;
87+
getParentName() {
88+
return this.app ? this.app.name : this.parent.name;
8289
},
8390

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

107+
getModuleRelativePath(fullPath) {
108+
return this.modulesPreprocessor.getModuleRelativePath(fullPath);
109+
},
110+
100111
getModulesTree() {
101112
return this.modulesPreprocessor.getModulesTree();
102113
},
@@ -121,6 +132,10 @@ module.exports = {
121132
return this.cssModulesOptions && this.cssModulesOptions.extension || 'css';
122133
},
123134

135+
includeExtensionInModulePath() {
136+
return !!this.cssModulesOptions.includeExtensionInModulePath;
137+
},
138+
124139
getPostcssOptions() {
125140
return this.cssModulesOptions.postcssOptions;
126141
},

lib/htmlbars-plugin/index.js

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,54 @@ const utils = require('./utils');
44
const semver = require('semver');
55

66
module.exports = class ClassTransformPlugin {
7-
constructor(options) {
8-
this.syntax = options.syntax;
9-
this.builders = options.syntax.builders;
10-
this.stylesModule = this.determineStylesModule(options.meta);
7+
constructor(env, options) {
8+
this.syntax = env.syntax;
9+
this.builders = env.syntax.builders;
10+
this.options = options;
11+
this.stylesModule = this.determineStylesModule(env);
1112
this.isGlimmer = this.detectGlimmer();
12-
this.visitor = this.buildVisitor();
13+
this.visitor = this.buildVisitor(env);
1314

1415
// Alias for 2.15 <= Ember < 3.1
1516
this.visitors = this.visitor;
1617
}
1718

18-
static forEmberVersion(version) {
19+
static instantiate({ emberVersion, options }) {
1920
return {
2021
name: 'ember-css-modules',
21-
plugin: semver.lt(version, '2.15.0-alpha')
22-
? LegacyAdapter.bind(null, this)
23-
: options => new this(options),
22+
plugin: semver.lt(emberVersion, '2.15.0-alpha')
23+
? LegacyAdapter.bind(null, this, options)
24+
: env => new this(env, options),
2425
parallelBabel: {
2526
requireFile: __filename,
26-
buildUsing: 'forEmberVersion',
27-
params: version
27+
buildUsing: 'instantiate',
28+
params: { emberVersion, options },
2829
},
2930
baseDir() {
3031
return `${__dirname}/../..`;
3132
}
3233
};
3334
}
3435

35-
determineStylesModule(meta) {
36-
if (!meta || !meta.moduleName) return;
36+
determineStylesModule(env) {
37+
if (!env || !env.moduleName) return;
38+
39+
let includeExtension = this.options.includeExtensionInModulePath;
40+
let name = env.moduleName.replace(/\.\w+$/, '');
3741

38-
let name = meta.moduleName.replace(/\.\w+$/, '');
3942
if (name.endsWith('template')) {
40-
return name.replace(/template$/, 'styles');
43+
name = name.replace(/template$/, 'styles');
4144
} else if (name.includes('/templates/')) {
42-
return name.replace('/templates/', '/styles/');
45+
name = name.replace('/templates/', '/styles/');
46+
} else if (name.includes('/components/')) {
47+
includeExtension = true;
48+
}
49+
50+
if (includeExtension) {
51+
name = `${name}.${this.options.fileExtension}`;
4352
}
53+
54+
return name;
4455
}
4556

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

55-
buildVisitor() {
66+
buildVisitor(env) {
67+
if (env.moduleName === env.filename) {
68+
// No-op for the stage 1 Embroider pass (which only contains relative paths)
69+
return {};
70+
}
71+
5672
return {
5773
ElementNode: node => this.transformElementNode(node),
5874
MustacheStatement: node => this.transformStatement(node),
@@ -212,14 +228,15 @@ module.exports = class ClassTransformPlugin {
212228

213229
// For Ember < 2.15
214230
class LegacyAdapter {
215-
constructor(plugin, options) {
231+
constructor(plugin, options, env) {
216232
this.plugin = plugin;
217-
this.meta = options.meta;
233+
this.options = options;
234+
this.meta = env.meta;
218235
this.syntax = null;
219236
}
220237

221238
transform(ast) {
222-
let plugin = new this.plugin({ meta: this.meta, syntax: this.syntax });
239+
let plugin = new this.plugin(Object.assign({ syntax: this.syntax }, this.meta), this.options);
223240
this.syntax.traverse(ast, plugin.visitor);
224241
return ast;
225242
}

lib/modules-preprocessor.js

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,48 @@
22

33
const Funnel = require('broccoli-funnel');
44
const MergeTrees = require('broccoli-merge-trees');
5+
const Bridge = require('broccoli-bridge');
56
const ensurePosixPath = require('ensure-posix-path');
67
const normalizePostcssPlugins = require('./utils/normalize-postcss-plugins');
78
const debug = require('debug')('ember-css-modules:modules-preprocessor');
9+
const fs = require('fs');
810

911
module.exports = class ModulesPreprocessor {
1012
constructor(options) {
1113
this.name = 'ember-css-modules';
1214
this.owner = options.owner;
15+
this._modulesTree = null;
16+
this._modulesBasePath = null;
17+
this._modulesBridge = new Bridge();
1318
}
1419

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

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

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

24-
getModulesTree() {
29+
buildModulesTree(modulesInput) {
2530
if (!this._modulesTree) {
2631
let inputRoot = this.owner.belongsToAddon() ? this.owner.getParentAddonTree() : this.owner.app.trees.app;
2732
let outputRoot = (this.owner.belongsToAddon() ? this.owner.getAddonModulesRoot() : '');
2833

29-
let modulesSources = new Funnel(inputRoot, {
34+
if (outputRoot) {
35+
inputRoot = new Funnel(inputRoot, {
36+
destDir: outputRoot,
37+
});
38+
}
39+
40+
let modulesSources = new ModuleSourceFunnel(inputRoot, modulesInput, {
3041
include: ['**/*.' + this.owner.getFileExtension()],
31-
destDir: outputRoot + this.owner.getOwnerName(),
42+
outputRoot,
43+
parentName: this.owner.getParentName()
3244
});
3345

34-
this._modulesTree = new (require('broccoli-css-modules'))(modulesSources, {
46+
let modulesTree = new (require('broccoli-css-modules'))(modulesSources, {
3547
extension: this.owner.getFileExtension(),
3648
plugins: this.getPostcssPlugins(),
3749
enableSourceMaps: this.owner.enableSourceMaps(),
@@ -40,6 +52,7 @@ module.exports = class ModulesPreprocessor {
4052
virtualModules: this.owner.getVirtualModules(),
4153
generateScopedName: this.scopedNameGenerator(),
4254
resolvePath: this.resolveAndRecordPath.bind(this),
55+
getJSFilePath: cssPath => this.getJSFilePath(cssPath, modulesSources),
4356
onBuildStart: () => this.owner.notifyPlugins('buildStart'),
4457
onBuildEnd: () => this.owner.notifyPlugins('buildEnd'),
4558
onBuildSuccess: () => this.owner.notifyPlugins('buildSuccess'),
@@ -49,9 +62,39 @@ module.exports = class ModulesPreprocessor {
4962
onImportResolutionFailure: this.onImportResolutionFailure.bind(this),
5063
formatJS: formatJS
5164
});
65+
66+
this._modulesTree = modulesTree;
67+
this._modulesBridge.fulfill('modules', modulesTree);
5268
}
5369

54-
return this._modulesTree;
70+
return this.getModulesTree();
71+
}
72+
73+
getModulesTree() {
74+
return this._modulesBridge.placeholderFor('modules');
75+
}
76+
77+
getModuleRelativePath(fullPath) {
78+
if (!this._modulesBasePath) {
79+
this._modulesBasePath = ensurePosixPath(this._modulesTree.inputPaths[0]);
80+
}
81+
82+
return fullPath.replace(this._modulesBasePath + '/', '');
83+
}
84+
85+
getJSFilePath(cssPathWithExtension, modulesSource) {
86+
if (this.owner.includeExtensionInModulePath()) {
87+
return `${cssPathWithExtension}.js`;
88+
}
89+
90+
let extensionRegex = new RegExp(`\\.${this.owner.getFileExtension()}$`);
91+
let cssPathWithoutExtension = cssPathWithExtension.replace(extensionRegex, '');
92+
93+
if (modulesSource.has(`${cssPathWithoutExtension}.hbs`)) {
94+
return `${cssPathWithExtension}.js`;
95+
} else {
96+
return `${cssPathWithoutExtension}.js`;
97+
}
5598
}
5699

57100
scopedNameGenerator() {
@@ -132,7 +175,7 @@ module.exports = class ModulesPreprocessor {
132175

133176
rootPathPlugin() {
134177
return require('postcss').plugin('root-path-tag', () => (css) => {
135-
css.source.input.rootPath = this.getModulesTree().inputPaths[0];
178+
css.source.input.rootPath = this._modulesTree.inputPaths[0];
136179
});
137180
}
138181

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

142185
return this._resolvePath(importPath, fromFile, {
143186
defaultExtension: this.owner.getFileExtension(),
144-
ownerName: this.owner.getOwnerName(),
187+
ownerName: this.owner.getParentName(),
145188
addonModulesRoot: this.owner.getAddonModulesRoot(),
146-
root: ensurePosixPath(this.getModulesTree().inputPaths[0]),
189+
root: ensurePosixPath(this._modulesTree.inputPaths[0]),
147190
parent: this.owner.getParent()
148191
});
149192
}
@@ -155,3 +198,31 @@ const EXPORT_POST = ';\n';
155198
function formatJS(classMapping) {
156199
return EXPORT_PRE + JSON.stringify(classMapping, null, 2) + EXPORT_POST;
157200
}
201+
202+
class ModuleSourceFunnel extends Funnel {
203+
constructor(input, stylesTree, options) {
204+
super(input, options);
205+
this.stylesTree = stylesTree;
206+
this.parentName = options.parentName;
207+
this.destDir = options.outputRoot;
208+
this.inputHasParentName = null;
209+
}
210+
211+
has(filePath) {
212+
let relativePath = this.inputHasParentName ? filePath : filePath.replace(`${this.parentName}/`, '');
213+
return fs.existsSync(`${this.inputPaths[0]}/${relativePath}`);
214+
}
215+
216+
build() {
217+
if (this.inputHasParentName === null) {
218+
this.inputHasParentName = fs.existsSync(`${this.inputPaths[0]}/${this.parentName}`);
219+
220+
let stylesTreeHasParentName = fs.existsSync(`${this.stylesTree.outputPath}/${this.parentName}`);
221+
if (stylesTreeHasParentName && !this.inputHasParentName) {
222+
this.destDir += `/${this.parentName}`;
223+
}
224+
}
225+
226+
return super.build(...arguments);
227+
}
228+
}

0 commit comments

Comments
 (0)