DEV Community

stereobooster
stereobooster

Posted on • Updated on

How to write simple babel macro

Macro is a small program which you can write to manipulate the source code of your application at transpilation (compilation) time. Think of it as a way to tweak how your compiler behaves.

babel-plugin-macros is a plugin for babel, to write macros for JavaScript (or Flow). The juicy part here is that as soon as babel-plugin-macros included you don't need to touch babel config to use your macros (contrary to other babel plugins). This is super useful in locked setups, like Creat React App. Also, I like that it is explicit - you clearly see where the macro is used.

Task

I picked up toy size problem which is easy to solve with macro.

When you use dynamic import in Webpack it will generate hard readable names for chunks (at least this is what it does in CRA), like 1.chunk.js, 2.chunk.js. To fix this you can use the magic comment /* webpackChunkName: MyComponent */, so you will get MyComponent.chunk.js, but this annoying to put this comment by hand every time. Let's write babel macro exactly to fix this.

We want code like this:

import wcImport from "webpack-comment-import.macro";

const asyncModule = wcImport("./MyComponent");
Enter fullscreen mode Exit fullscreen mode

To be converted to

const asyncModule = import(/* webpackChunkName: MyComponent */ "./MyComponent");
Enter fullscreen mode Exit fullscreen mode

Boilerplate

So I want to jump directly to coding, so I won't spend time on boilerplate. There is a GitHub repo with the tag boilerplate, where you can see the initial code.

export default createMacro(webpackCommentImportMacros);
function webpackCommentImportMacros({ references, state, babel }) {
  // lets walk through all calls of the macro
  references.default.map(referencePath => {
    // check if it is call expression e.g. someFunction("blah-blah")
    if (referencePath.parentPath.type === "CallExpression") {
      // call our macro
      requireWebpackCommentImport({ referencePath, state, babel });
    } else {
      // fail otherwise
      throw new Error(
        `This is not supported: \`${referencePath
          .findParent(babel.types.isExpression)
          .getSource()}\`. Please see the webpack-comment-import.macro documentation`,
      );
    }
  });
}
function requireWebpackCommentImport({ referencePath, state, babel }) {
  // Our macro which we need to implement
}
Enter fullscreen mode Exit fullscreen mode

There are also tests and build script configured. I didn't write it from scratch. I copied it from raw.macro.

Let's code

First of all get babel.types. Here is the deal: when you working with macros, mainly what you do is manipulating AST (representation of source code), and babel.types contains a reference to all possible types of expressions used in babel AST. babel.types readme is the most helpful reference if you want to work with babel AST.

function requireWebpackCommentImport({ referencePath, state, babel }) {
  const t = babel.types;
Enter fullscreen mode Exit fullscreen mode

referencePath is wcImport from const asyncModule = wcImport("./MyComponent");, so we need to get level higher, to actual call of function e.g. wcImport("./MyComponent").

  const callExpressionPath = referencePath.parentPath;
  let webpackCommentImportPath;
Enter fullscreen mode Exit fullscreen mode

Now we can get arguments with which our function was called, to make sure there is no funny business happening let's use try/catch. First argument of function call supposes to be a path of the import e.g. "./MyComponent".

  try {
    webpackCommentImportPath = callExpressionPath.get("arguments")[0].evaluate()
      .value;
  } catch (err) {
    // swallow error, print better error below
  }

  if (webpackCommentImportPath === undefined) {
    throw new Error(
      `There was a problem evaluating the value of the argument for the code: ${callExpressionPath.getSource()}. ` +
        `If the value is dynamic, please make sure that its value is statically deterministic.`,
    );
  }
Enter fullscreen mode Exit fullscreen mode

Finally AST manipulation - let's replace wcImport("./MyComponent") with import("./MyComponent");,

  referencePath.parentPath.replaceWith(
    t.callExpression(t.identifier("import"), [
      t.stringLiteral(webpackCommentImportPath),
    ]),
  );
Enter fullscreen mode Exit fullscreen mode

Let's get the last part of the path e.g. transform a/b/c to c.

  const webpackCommentImportPathParts = webpackCommentImportPath.split("/");
  const identifier =
    webpackCommentImportPathParts[webpackCommentImportPathParts.length - 1];
Enter fullscreen mode Exit fullscreen mode

And put the magic component before the first argument of the import:

  referencePath.parentPath
    .get("arguments")[0]
    .addComment("leading", ` webpackChunkName: ${identifier} `);
}
Enter fullscreen mode Exit fullscreen mode

And this is it. I tried to keep it short. I didn't jump into many details, ask questions.

PS

Babel documentation is a bit hard, the easiest way for me were:

  1. inspect type of the expression with console.log(referencePath.parentPath.type) and read about it in babel.types
  2. read the source code of other babel-plugin which doing a similar thing

The full source code is here

Hope it helps. Give it a try. Tell me how it goes. Or simply share ideas of you babel macros.

Follow me on twitter and github.

Latest comments (9)

Collapse
 
moranf profile image
Moran Fine

Hi, how do you unit test this Babel macro?

Collapse
 
stereobooster profile image
stereobooster

With AVA and snapshots. See here

Collapse
 
moranf profile image
Moran Fine • Edited

When I try to run the tests in IDEA, using Jest, I get "Empty tests suite", and it says that the tests passed: 0 of 2 tests, and it's also not producing any snapshots. Is there any other way to run them?

Thread Thread
 
stereobooster profile image
stereobooster

Oh wait a second you right it is Jest. Why I thought it was AVA? Anyway, you should be able to run npm test in console.

Thread Thread
 
moranf profile image
Moran Fine

Do you know maybe how can I import several custom babel-macros from one place in my project (For example index.js)?

Thread Thread
 
stereobooster profile image
stereobooster

As far as I know this is not possible, this kind of contradicts the idea of macro. You need explicitly declare in each file which macro you want to use. If you want something to be applied everywhere you need to use babel-plugin for this.

I can be wrong here. You can open issue in babel-macro repository with this question.

Thread Thread
 
moranf profile image
Moran Fine

Thank you for your answers!
Is that possible to at least to import the macro from an absolute path instead of the relative one?

Thread Thread
 
stereobooster profile image
stereobooster

Yes, should be possible. I don't see why not

Collapse
 
pojntfx profile image
Felicitas Pojtinger

define for JS. Love it!