@radekmie

On CSS Modules in Meteor

By Radosław Miernik · Published on · Comment on Meteor Forum, Reddit

Table of contents

Intro

We recently started working on a design system in a Meteor app. One of the most important decisions we had to make was deciding on how we are going to style it. Plain CSS or SCSS? CSS-in-JS, and if so, which library? Or Tailwind CSS? All of those options have their ups and downs, and we ended up with…

None of the above. We decided to go with CSS Modules, i.e., plain CSS but with locally-scoped class names. On top of that, we decided to use the CSS Custom Properties, a.k.a. CSS variables1. It all plays well with Figma, so collaboration with our designer is even easier.

Great! Now let’s set it up in our app… There’s nathantreid:css-modules; let’s give it a try. It works, though it hasn’t been maintained for more than five years now. Normally, it’s not a problem for me, but it depends on really old npm packages, and that’s a compatibility problem with the rest of the app2.

That’s why we’re going to make our own.

Meteor packages

Every single piece of the Meteor framework is a package1. It’s “just like” npm packages but with a different set of goals, requirements, and functionalities. You can find the documentation here, but I’ll try to make the next sections self-explanatory enough for you to follow.

First, we need to create a directory where we’ll develop our package. By convention, it has to be in /packages, so /packages/css-modules it is. The first file will be package.js, which is just like package.json in npm:

Package.describe({
  // `xxx` for core packages and `xxx:yyy` for community ones.
  name: 'radekmie:css-modules',
  summary: 'CSS modules implementation.',
  version: '1.0.0',
});

That’s enough to define our package. It doesn’t do anything, but we can add it as a dependency to our application now:

meteor add radekmie:css-modules

Build plugins

Every package can provide some code that is available in the application either globally or via imports; nothing fancy, that’s how npm works too. But on top of that, every package can register an arbitrary number of build plugins: linters, compilers, and minifiers; each doing what the name says.

Since our goal is to make the CSS classes available in JavaScript code, we need to generate some JavaScript module based on the CSS one. In other words, we’ll translate the CSS files into a CSS asset and a JSON mapping of the classes.

Let’s register a compiler plugin and add those lines to package.js:

Package.registerBuildPlugin({
  // Name of the build plugin; used in error reporting.
  name: 'radekmie:css-modules',

  // List of dependencies.
  use: [
    'babel-compiler',   // Meteor-specific JS compiler.
    'caching-compiler', // Out-of-box cache in compiler.
    'ecmascript',       // Support for ES6 classes.
    'minifier-css',     // Meteor-specific CSS minifier.
  ],

  // List of files with the compiler.
  sources: ['compiler.js'],
});

// Every compiler plugin has to rely on this package.
Package.onUse(api => {
  api.use(['isobuild:compiler-plugin']);
});

In total, our (formatted) package.js has 15 lines. The number of lines alone is barely ever a problem on its own, but since we’re adding to our application’s infrastructure, I’m on the “the less the better” side.

Compiler plugin

Our build plugin is set up – now, let’s create the compiler. First, we need to know what we are going to build exactly. Since we’re adding it to an existing application with .css files in it, we’ve decided to go with the .m.css extension. All of this code goes to /packages/css-modules/compiler.js:

Plugin.registerCompiler(
  {
    // We need to build it only for the browser.
    archMatching: 'web',
    // Build all files with any of these extensions.
    extensions: ['m.css'],
  },
  // Create a compiler instance (we'll define it in a second).
  () => new CSSModulesCompiler(),
);

And here’s our compiler:

import { CachingCompiler } from 'meteor/caching-compiler';

class CSSModulesCompiler extends CachingCompiler {
  constructor() {
    super({
      // Name of the compiler; used in error reporting.
      compilerName: 'radekmie:css-modules',
      // Configuration of the `CachingCompiler`; 8MB is enough.
      defaultCacheSize: 8 * 1024 * 1024,
    });
  }

We’re building on top of the Core caching-compiler package. You can read in its documentation how the cache works, but it’s a default for all compilers. We need to implement a few functions, so let’s do that:

// We're adding new methods.
class CSSModulesCompiler extends CachingCompiler {
  // Create assets given a `file` and a result of `compileOneFile`.
  // The most important `file` methods are described here:
  // https://docs.meteor.com/api/packagejs.html#build-plugin-api.
  addCompileResult(file, compileResult) {
    // Create a single JavaScript asset eagerly.
    file.addJavaScript({ ...getFileInfo(file), ...compileResult });
  }

  // Same as `addCompileResult` but for optional resources. In short,
  // it allows the compiler to be run only when the files are actually
  // imported (and not eagerly).
  compileOneFileLater(file, getCompileResult) {
    // Create a single JavaScript asset lazily.
    file.addJavaScript(getFileInfo(file), getCompileResult);
  }

  // Calculate cache size out of a result of `compileOneFile`.
  compileResultSize(compileResult) {
    // We cache only the JavaScript asset, so the size of it is enough.
    return compileResult.data.length;
  }

  // Calculate cache key of a `file`.
  getCacheKey(file) {
    // Use the hash of the `.m.css` file.
    return file.getSourceHash();
  }
}

// Decoupled logic of extracting metadata out of `file`.
function getFileInfo(file) {
  const sourcePath = file.getPathInPackage();
  return {
    // Compile it only if the file is actually imported.
    // (By default all files are compiled.)
    lazy: true,
    // Path of the asset target. Just like `sourcePath` below,
    // it's important for error reporting and has to be unique.
    path: sourcePath,
    // Path of the asset source.
    sourcePath,
  };
}

All of the above wiring integrates our compiler with the Meteor build system and the caching-compiler. Yes, it’s kind of a boilerplate, but that’s inevitable in any build tool I worked with (Parcel, Webpack, Vite).

Actual compilation

In the previous sections, we configured everything needed for our compiler. The only missing thing is the actual compilation, so let’s do that:

import { Babel } from 'meteor/babel-compiler';
import { CachingCompiler } from 'meteor/caching-compiler';
import { CssTools } from 'meteor/minifier-css';
import postcss from 'postcss';
import postcssModules from 'postcss-modules';

// We're adding a new method.
class CSSModulesCompiler extends CachingCompiler {
  async compileOneFile(file) {
    const sourcePath = file.getPathInPackage();

    // Call PostCSS with a single `postcss-modules` plugin. Yes,
    // we leave all of the heavy lifting to this package. We don't
    // need the source maps, but the `from` and `to` options are
    // critical for deterministic CSS class names generation.
    let classMap = {};
    let { css } = await postcss([
      postcssModules({
        // Prefix hashes with `_` since CSS doesn't like classes
        // starting with numbers.
        generateScopedName: '_[contenthash:8]',
        getJSON: (_, json) => {
          classMap = json;
        },
      }),
    ]).process(file.getContentsAsString(), {
      from: sourcePath,
      map: false,
      to: sourcePath,
    });

    // If we're compiling for production, minimize the resulting
    // CSS code with Meteor's default minifier. We could do that by
    // using `cssnano` directly, it's fast enough and future-proof.
    if (process.env.NODE_ENV === 'production') {
      [css] = CssTools.minifyCss(css);
    }

    // Here we define the actual JavaScript module added in the app's
    // bundle. To make it easier, we pass it through the default
    // Meteor's JavaScript compiler - it'll take care of minifying it
    // and translating `export default` with `reify`.
    const { code } = Babel.compile(
      `
      require('meteor/modules').addStyles(${JSON.stringify(css)});
      export default ${JSON.stringify(classMap)};
      `,
      null,
      { cacheDirectory: this._diskCache },
    );

    // Return the compiled code.
    return { data: code };
  }
}

In short, we read the .m.css file contents and use PostCSS with the postcss-modules plugin on it. When compiling for production, minify it using Meteor’s standard minifier-css. Then, create a JavaScript snippet that registers the stylesheet using the modules Meteor package and exports the CSS class map. It may feel super dense, but that’s common with glue code, I think.

You may noticed we’re using some npm packages here: postcss and postcss-modules. We could add them to the package’s npm dependencies (see Npm.depends), but that separates the installation from the other packages (that’s how Meteor solves it). Instead, we decided to add them to the app’s dependencies instead (i.e., app’s package.json).

Usage

The compiler is now ready – let’s use it! Let’s create an example Paragraph component in our design system. First, the styles in Paragraph.m.css:

.Paragraph {
  color: var(--paragraph-color);
}

Next, Paragraph.js:

import styles from './Paragraph.m.css';

export function Paragraph({ children }) {
  return <p className={styles.Paragraph}>{children}</p>;
}

And that’s it! Welcome, my dear _e0e35c5c.

Developer experience

If you use the above code, you will see that changing the .m.css files will trigger a page reload. That’s not ideal for the developers since it makes the development longer. But Meteor supports hot module replacement, doesn’t it?

It has been since Meteor 2.0 (January 2021), but that requires a little work on our end. Let’s change our JavaScript snippet a little:

-require('meteor/modules').addStyles(${JSON.stringify(css)});
+const style =
+  require('meteor/modules').addStyles(${JSON.stringify(css)});
+if (module.hot) {
+  module.hot.accept();
+  module.hot.dispose(() => {
+    style.remove();
+  });
+}
+
 export default ${JSON.stringify(classMap)};

The most important part is the module.hot.accept(). That marks this module as “replaceable” and thus not requiring a full page reload. Additionally, we have to clean up the previous execution, i.e., remove the previous <style> tag.

Of course, the above code could be eliminated in the production bundle, but we decided to skip that for now. On top of that, we could somehow make it part of the CSS bundle instead of the JavaScript bundle, but I didn’t have time to look into that (file.addStylesheet looks like it, but then it’s not lazy).

TypeScript?

All of the above snippets are not using TypeScript. That’s bad since we’d like all of our codebase to be typed! The problem is there are no typings for this part of Meteor yet, and we just agreed to make an exception.

However, we don’t want to @ts-expect-error every single .m.css import in our React components! Luckily, TypeScript has us covered with its wildcard module declarations. Let’s create a /types/css-modules.d.ts:

declare module '*.m.css' {
  const Styles: Record<string, string>;
  export = Styles;
}

That’s not ideal since we can have a typo in our class name, but that’s better than nothing. To make it better, we could generate typings based on the .m.css files and put them somewhere where TypeScript could find them2.

Custom PostCSS plugin

Since we’re calling PostCSS ourselves, we can easily create our own plugins. We actually had to make one since the CSS Custom Properties do not work in CSS Media Queries. And just like Meteor plugins, PostCSS plugins are simple!

// Map of custom media queries to their CSS counterparts.
const media = {
  '--breakpoint-mobile': '(max-width: 767px)',
  '--breakpoint-tablet': '(max-width: 1023px)',
};

// Cached getter and regex.
const mediaGetter = name => media[name];
const mediaRegex = new RegExp(`(${Object.keys(media).join('|')})`, 'g');

/** @type {import('postcss').AcceptedPlugin} */
const postcssCustomMediaPlugin = {
  postcssPlugin: 'postcss-custom-media',
  prepare: () => ({
    AtRule(atRule) {
      // If it's a `@media` rule with `--` in it...
      if (atRule.name === 'media' && atRule.params.includes('--')) {
        // ...replace all custom media queries.
        atRule.params = atRule.params.replace(mediaRegex, mediaGetter);
      }
    },
  }),
};

// Remember to add `postcssCustomMediaPlugin` to the `postcss` instance!

This simple plugin allows us to write the following code:

@media --breakpoint-tablet {
   .Paragraph:has(img) {
     padding-inline: var(--spacing-s);
   }
 }

And if you need other plugins, see www.PostCSS.parts for more. Remember to use autoprefixer too – it can save you a few bug reports!

Closing thoughts

Creating plugins is a fun way of learning that inevitably forces you to get a deeper understanding of the tools you work with. And whether it’s something you see for the first time (like me with the PostCSS above) or something you have worked with for years (Meteor, yay!) – it’s great on all fronts.

I wanted to show you that custom integrations are not necessarily complex or huge – we managed to keep those two plugins in 122 (formatted) lines. It shouldn’t be a problem to maintain them too since we have control over everything (maybe except for postcss-modules, but it’s not big either).

Finally, you may ask why - won’t I make it into a package? The answer is simple: I don’t have the capacity to maintain it. Ah, and I’d also need to document it, add tests, update dependencies once in a while… And come up with an interface for configuring plugins since it’s hardcoded right now.

Now I’ll continue working on the plugin for the next month…

3

I didn’t check for the browser support of CSS Custom Properties recently and was surprised to see that it’s available in all maintained browsers. For more than six years, actually! That’s what I get for reading only about new features and not the stabilization of the standardized ones. Well, almost standardized.

4

The biggest issue was the supported Browserslist queries.

1

There are tools on top of that, but I feel confident generalizing here.

2

We could put them next to the .m.css files, but ideally, it’d be hidden from the user. An alternative would be to create symlinks, just like zodern:types.