css-modules/css-modules-loader-core

Proposal: Fallback path for theming

andywer opened this issue · 10 comments

Hi there!

We are trying to theme our CSS modules. It would be a huge benefit if we could do something like this:

:import("../fancy-theme/colors.css || ../base-theme/colors.css") { ... }

This would allow us to have a specific and a base theme and fallback to the base theme's file if we don't override it in the other one. The import file path with the || could be generated by our theming plugin (https://github.com/andywer/postcss-theme).

We could implement it ourselves and open a PR.
But do you actually like the concept?

Quick feedback would be highly appreciated :)
Thanks in advance!

ai commented

I like the concept of postcss-theme — really great solution. But why postcss-theme also need this changes?

The reason is that in the field you usually don't have two completely different themes, but instead a base theme and another theme that overrides some of the base theme's variables.

Currently you have to provide every file that exists in the base theme also in the other themes. It is not a deal-breaker, I think, but for real-world use cases it is more desirable to be able to only override files of the base theme whose variables you really want to change.

In SASS you would probably solve that by using !default and import the base theme's files first, other theme's files later. But in CSS modules you cannot go this way due to the missing !default and the local scope.

I am quite curious, though, if only theming would benefit from the proposed feature or if someone comes up with another use case for it as well.

Thanks for submitting the idea! I like what you're doing with postcss-theme but I don't think we should extend :import to have to deal with files not being there. It seems like a big addition of complexity, and the behaviour of "if X is present, load it, otherwise load Y" isn't something that css modules needs to be concerned with.

I wonder if you could write a webpack require resolver that understands this theming behaviour, so that if a file is not found it looks for the same file in ../../base/[name].css or something. CSS Modules doesn't care if the file it requests (../fancy-theme/colors.css) doesn't actually exist, as long as it gets its exports so it can pass through @value and composes statements.

But I think there could be a better way, that makes more sense with the postcss-theme idea. You could introduce the idea of a theme override file, like so:

@theme-override "../base-theme/colors.css";

@value black: #303030;

Because ../base-theme/colors.css is a CSS Module you can get its list of :exports, and for every one you don't override, simply inject a pass-through @value statement, resulting in:

@value black: #303030; /* redefined */
@value white from "../base-theme/colors.css"; /* passed through */

Or, if you prefer, skip the @value and go straight to ICSS:

@value black: #303030; /* redefined */
:export { white: __imported_white; }
:import("../base-theme/colors.css") { __imported_white: white; }

Would that be sufficient for your use case?

ai commented

I think CSS Modules should not be focused on webpack. We have Rollup and many other good things.

And because theming is a one if of the biggest question for components, we definitely should have good solution for it.

But this :import really looks too tricky and complicated. I think we should try to make it more elegant.

ai commented

Why we can't load main component theme to our custom theme and export again all unchanged variables?

We can make some postcss-extend-module plugin for it.

Yeah, it would be great to have postcss as loader-agnostic as possible (actually I am in JSPM/System.js land right now). I understand that changing :import behavior is a major thing and shouldn't be done unless really necessary.

Regarding the @theme-override/postcss-extend-module idea: Is it possible at all to enumerate all exports of a css module at the time when the plugins are run?
The underlying css loader can surely do this, but I have some serious doubts a plugin could do it, since when the plugin is run the base file (whose values we are going to override) is probably not yet loaded.

ai commented

@andywer it is a question for @geelen but at least you can always take file AST and fine all necessary at-rules by hands.

Yeah I didn't mean webpack-specific, just loader-specific & project-specific. Either/or path rules seem like something you'd attach at the loader level, not a core language feature.

As for the plugins actually enumerating their dependencies, you're right, that's a bit tricky. At the moment almost all the plugins for CSS Modules can run on their own file, before any imports are resolved. We could find a place for the plugin the way you describe, but I have an issue:

/* base.css */
@value black: #222;
@value white: #EEE;
/* override.css */
@extend-module "../base.css";

@value black: #333;

.foo {
  background: black;
  color: white;
}

What color does .foo get? You've implicitly imported white from base but because this isn't Sass, you don't use variables like $white so you don't even know it's been redefined. This is one of the core concepts of CSS Modules, you shouldn't have magic at a distance, you need to import everything you depend on and export everything explicitly.

So, I think the way you're proposing, where you load theme.css if it's present and load base.css if it's not, is better than an extend-module directive. But I am reluctant to include it in core CSS Modules. In JSPM land, you have the locate function for rewriting paths to resources. if you could inject the logic there, would that be sufficient?

One idea I've been playing with as an answer to theming is: what if we treated a css module's @values as inputs? So you can import a parameterized instance of a css module.

Eg.

/* shared/rounded-box.css */

@value radius 10px; /* this is the default */

.box {
  border-radius: radius;
}
/* my-component.css */

@module box from "./shared/rounded-box.css" with {
  radius: 22px;
}

.root {
  composes: box;
}

Any thoughts on this? Would mean a few extra bits of syntax, as well as treating composed modules as instances rather than globals. But I think it would work nicely as a theming solution, even for publishing themeable modules to npm.

@geelen Yeah, I completely agree. I will just extend the loader. Maybe it makes sense to not change the loader itself, but create a new loader that inherits from the jspm-loader-css and provides the additional logic.

@joshwnj Interesting concept. I personally like it, but it is in fact hard to see that the @values can be overridden if the comment is missing ;) Maybe add a @param directive which is an alias for @value, but only those @param can be overridden from the outside...

Is there yet any way to pass data from one module into another?
PS: Would be cool if it would also work with https://github.com/postcss/postcss-mixins