Extract all react-router non-dynamic routes with SWC

25 Mar, 2022React, SWC, TypeScript, AST

Introduction

Having a script to extract all the react-router non-dynamic routes from your source code, so you can:

  • Structure your routes in a nested way as suggested by react-router's philosophy. Instead of having a single centralised route file, you can put the routes definitions closer to the actual components, making it easier to maintain and understand.
  • To generate a routes.json (or paths.json or pages.json) file to be consumed by code that outside of the react-router context for runtime route matching, such as non-component utilities, Service Worker (workbox-routing) or server side route-rewriting.
  • To generate types for all your routes with path parameters, so when you can wrap the generatePath with the generated type and make it type-safe.

Concept

By parsing the code into AST, and walking through the AST to find the route values is a bit complex than regular expression, but it's a lot more accurate.

SWC is a JavaScript and TypeScript compiler written in Rust, as the replacement of Babel and TypeScript Compiler (tsc). The biggest benefit of SWC over TSC is its speed, I did a bit of benchmark and here's the result:

$ npx ts-node scripts/parse-with-tsc.ts
1143.3103ms

$ npx ts-node scripts/parse-with-swc.ts
3.5098ms

So basically, SWC is 300 times faster than TSC. And imagine to process a large project with hundreds of files, the TSC could take few minutes to finish, while SWC could take just a few seconds.

SWC support plugins and the most useful one is the Visitor API. It allows use to define a custom Visitor class and extend the default visitor methods of each type of AST node.

In order to implement our custom Visitor class, we need to know what exact the node structure we're looking for. SWC provides a playground web app to preview the AST: https://swc.rs/playground. You can also use this TypeScript AST Viewer which is parsed by TSC but the web app itself has better interactive features like highlighting the node you're hovering over.

Step 1

Install the dependencies:

npm i -D ts-node fast-glob @swc/core

Step 2

Add the ts-node section to the tsconfig.json:

{
  "ts-node": {
    "swc": true,
    "compilerOptions": {
      "module": "commonjs",
      "target": "esnext",
    }
  }
}

Step 3

Create a scripts/extract-routes.ts script:

import fg from 'fast-glob';
import { JSXOpeningElement, parseFile } from '@swc/core';
import { Visitor } from '@swc/core/Visitor.js';

class RouteVisitor extends Visitor {
  paths: string[] = [];
  visitTsType(node: any) {
    return node;
  }
  visitJSXOpeningElement(node: JSXOpeningElement) {
    if (node.name.type === 'Identifier' && node.name.value === 'Route') {
      if (node.attributes?.length) {
        node.attributes.forEach(attr => {
          if (attr.type === 'JSXAttribute' && attr.value) {
            if (attr.name.type === 'Identifier' && attr.name.value === 'path') {
              if (attr.value.type === 'StringLiteral') {
                this.paths.push(attr.value.value);
                return;
              }
              if (
                attr.value.type === 'JSXExpressionContainer' &&
                attr.value.expression.type === 'ArrayExpression'
              ) {
                attr.value.expression.elements.forEach(element => {
                  if (element?.expression.type === 'StringLiteral') {
                    this.paths.push(element.expression.value);
                    return;
                  }
                });
              }
            }
          }
        });
      }
    }
    return node;
  }
}

(async () => {
  const allPaths = {};
  const tsxFiles = await fg('src/**/*.tsx');

  for await (const filePath of tsxFiles) {
    const program = await parseFile(filePath, {
      syntax: 'typescript',
      tsx: true,
      comments: false,
      target: 'es2022'
    });
    const ast = program.body;

    if (
      !ast.find(
        node =>
          node.type === 'ImportDeclaration' &&
          ['react-router', 'react-router-dom'].includes(node.source.value) &&
          node.specifiers.some(specifier => specifier.local.value === 'Route')
      )
    ) {
      continue;
    }

    const visitor = new RouteVisitor();
    try {
      visitor.visitProgram(program);
    } catch (error) {
      console.error('Error', filePath);
    }

    visitor.paths.forEach(path => {
      allPaths[path] = true;
    });
  }

  console.log(Object.keys(allPaths).sort());
})().catch(error => {
  console.error(error);
  process.exit(1);
});

Step 4

Test the script with a src/Test.tsx:

import React from 'react';
import { Route, Switch } from 'react-router';
import { Route as Route2 } from 'react-router-dom';

const Test = () => {
  return (
    <Switch>
      <Route exact path="/test1" />
      <Route exact path={['/test2', '/test3']} />
      <Route {...{ path: '/test4' }} />
      <Route {...{ path: ['/test5', '/test6'] }} />
      <Route2 path="/test7" />
    </Switch>
  );
};

export default Test;

However, in this example, you'll only get this from the output:

['/test1', '/test2', '/test3']

That's because the RouteVisitor only looks for routes within JSXAttribute nodes but {...{ path: '/test4' }} is a different type of node, called JsxSpreadAttribute.

And '/test7' is also not matched as the RouteVisitor only looks for JSX element named Route.

If you need to match all the routes, you further extend the RouteVisitor class to cover those 2 cases.


Powered by Gatsby. Theme inspired by end2end.

© 2014-2022. Made withby mdluo.