felfel.dev

Abstract Syntax Trees by example

January 5, 2021 • ☕️ 4 min read

I’m currently working on a side project which needs a lot of JSX/HTML parsing, manipulation, and generation using Abstract Syntax Trees (AST).

ASTs are very powerful and you can use them to build your own babel plugins, macros, or use directly as part of your app to do custom parsing, and manipulations of your code.

You can read more about the basics of ASTs here:

One major resource you will need to check a lot while working on ASTs is the babel types document, but one major issue i found with it is the lack of examples on how to use the different methods and what type of code will be expected as an output.

So as a resource for myself and others, I’m collecting here some examples from my own usage, and will be updating this post over time with new ones as i’m going on:

For all the examples, assume I’m importing these libs:

import * as parser from "@babel/parser";
import * as t from "@babel/types";
import traverse from "@babel/traverse";
import generate from "@babel/generator";

Add an attribute to a JSX element:

const snippet = '<button>Click Me</button>';

// Generating the AST
const ast = parser.parse(snippet, {
      plugins: ["jsx"],
    });

// Manipulation of the AST
traverse(ast, {
      JSXOpeningElement(path) {
        if (path.node.name.name === "button" &&
					!path.node.attributes.some((attr) => attr.name.name === "visited")) {
          path.node.attributes.push(
            t.jsxAttribute(
              t.jsxIdentifier("disabled"),
              t.JSXExpressionContainer(t.booleanLiteral(true))
            )
          );
        }
      },
    });

// Generate code
const code = generate(ast);

// <button disabled={true}>Click Me</button>

Note: the visited param name in the above condition is to avoid another traversal in the same node and causing an infinite loop as babel doesn’t guarantee visiting the same node only one time. there might be a better way of doing it but till i figure it out, that is the reason for the condition.

Wrapping an Element in a new one:

const snippet = '<button>Click Me</button>';
const ast = parser.parse(snippet, {
      plugins: ["jsx"],
    });

traverse(ast, {
      JSXOpeningElement(path) {
        if (path.node.name.name === "button" && 
					!path.node.attributes.some((attr) => attr.name.name === "visited")) {

          const container = t.jsxElement(
            t.jsxOpeningElement(t.jSXIdentifier("ExampleComponent"), []),
            t.jsxClosingElement(t.jSXIdentifier("ExampleComponent")),
            [path.container],
            false
          );
          path.parentPath.replaceWith(container);
        }
      },
    });

// Generate code
const code = generate(ast);

// <ExampleComponent><button visited={true}>Click Me</button></ExampleComponent>

Get all key/val props of an Element

const getAttrValue = (node) => {
	// return the value if it is string 
  if (node.value.value) return node.value.value;
  // sometimes the value is held in {} like numbers or boolean values
  if (node.value.type === "JSXExpressionContainer") {
    return node.value.expression.value;
  }
  // TODO: handle other values. e.g. functions
  return null;
};

// componentName: can be any custom react or html component e.g. Flex or div
// ast: the ast of the component you want to pick its props
const getComponentProps = (ast, componentName) => {
    const attrsMap = new Map();
    traverse(ast, {
      JSXOpeningElement(path) {
        if (path.node.name.name === componentName) {
          path.node.attributes.filter((node) => {
            const attributeValue = getAttrValue(node);
            if (attributeValue) {
              return attrsMap.set(node.name.name, attributeValue);
            }
          });
        }
      },
    });
    // fromEntries turns the map into a {key:val} object, you can just return the map itself if that what you want
    return Object.fromEntries(attrsMap);
  }

Apply New Props to an Element

// apply the new props to the selected component, then generate the JSX

// property, value are the name and the value of the prop we want to add
// attrType is the type of the prop, I'm passing custom types e.g. multiValueString, boolean ...etc and mapping them to babel/types function

const applyPropsToComponent = (ast, property, value, attrType) => {
    const { name, id, property, value, componentType: type } = event;
    traverse(ast, {
      JSXOpeningElement(path) {
        if (path.node.name.name === name) {
          const typeFn = attrTypeToBabelType(type, t);
          path.node.attributes.push(
            t.jsxAttribute(t.jsxIdentifier(property), typeFn(value))
          );
        }
      },
    });
    return generate(ast).code;
  },
});

// mapping attribute types to a babel/types function
const attrTypeToBabelType = (type, t) => {
  switch (type) {
    case "string":
    case "multiValueString":
    case "number":
      return t.stringLiteral;
    case "boolean":
      return t.booleanLiteral;

    default:
      return () => {};
  }
};

To render the generated JSX output, I use this lib react-jsx-parser, I checked its source code and it seems to be using Acorn which is another AST parser/generator (babel is actually based on Acorn).

Some other good resources I found for examples of how to use a specific method:

Also, I found AST Explorer to be useful in quickly trying specific methods and see their output, but what I missed there was the autocomplete of each method signature that VS code gives. (Kent C. Dodds in this video shows how to use it to build babel macros).

Thanks for reading!