Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Force React component naming with TypeScript

There is React+TypeScript application, and all component classes should be upper-cased and have Component suffix, e.g:

export class FooBarComponent extends React.Component {...}

The application is ejected create-react-application application, i.e. is build with Webpack.

How can component naming be forced to be consistent with style guide, at least for component classes, with error being thrown on build when there are inconsistencies?

I believe this cannot be achieved with TSLint/ESLint alone. If different methods should be used for TypeScript and JavaScript, solutions for both languages would be helpful.

like image 769
Estus Flask Avatar asked Mar 09 '18 15:03

Estus Flask


2 Answers

I can offer you only solution for typescript.

I believe this cannot be achieved with TSLint/ESLint alone.

There is a so-called rule class-name that can solve your issue partially but seems you need to write custom rule for such case.

So let's try writing such custom tslint rule. For that we need to use rulesDirectory option in tslint config to specify path to custom rules

"rulesDirectory": [
    "./tools/tslint-rules/"
],

Since I'm going to write custom rule in typescript I will be using one feature that was added in [email protected]

[enhancement] custom lint rules will be resolved using node's path resolution to allow for loaders like ts-node (#3108)

We need to install ts-node package

npm i -D ts-node

Then add fake rule in tslint.json

"ts-loader": true,

and create file tsLoaderRule.js in our rulesDirectory:

const path = require('path');
const Lint = require('tslint');

// Custom rule that registers all of the custom rules, written in TypeScript, with ts-node.
// This is necessary, because `tslint` and IDEs won't execute any rules that aren't in a .js file.
require('ts-node').register({
    project: path.join(__dirname, '../tsconfig.json')
});

// Add a noop rule so tslint doesn't complain.
exports.Rule = class Rule extends Lint.Rules.AbstractRule {
    apply() {}
};

This is basically approach which is widely used in angular packages like angular material, universal etc

Now we can create our custom rule(expanded version of class-name rule) that will be written in typescript.

myReactComponentRule.ts

import * as ts from 'typescript';
import * as Lint from 'tslint';

export class Rule extends Lint.Rules.AbstractRule {
  /* tslint:disable:object-literal-sort-keys */
  static metadata: Lint.IRuleMetadata = {
    ruleName: 'my-react-component',
    description: 'Enforces PascalCased React component class.',
    rationale: 'Makes it easy to differentiate classes from regular variables at a glance.',
    optionsDescription: 'Not configurable.',
    options: null,
    optionExamples: [true],
    type: 'style',
    typescriptOnly: false,
  };
  /* tslint:enable:object-literal-sort-keys */

  static FAILURE_STRING = (className: string) => `React component ${className} must be PascalCased and prefixed by Component`;

  static validate(name: string): boolean {
    return isUpperCase(name[0]) && !name.includes('_') && name.endsWith('Component');
  }

  apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
    return this.applyWithFunction(sourceFile, walk);
  }
}

function walk(ctx: Lint.WalkContext<void>) {
  return ts.forEachChild(ctx.sourceFile, function cb(node: ts.Node): void {
    if (isClassLikeDeclaration(node) && node.name !== undefined && isReactComponent(node)) {
      if (!Rule.validate(node.name!.text)) {
        ctx.addFailureAtNode(node.name!, Rule.FAILURE_STRING(node.name!.text));
      }
    }
    return ts.forEachChild(node, cb);
  });
}

function isClassLikeDeclaration(node: ts.Node): node is ts.ClassLikeDeclaration {
  return node.kind === ts.SyntaxKind.ClassDeclaration ||
    node.kind === ts.SyntaxKind.ClassExpression;
}

function isReactComponent(node: ts.Node): boolean {
  let result = false;
  const classDeclaration = <ts.ClassDeclaration> node;
  if (classDeclaration.heritageClauses) {
    classDeclaration.heritageClauses.forEach((hc) => {
      if (hc.token === ts.SyntaxKind.ExtendsKeyword && hc.types) {

        hc.types.forEach(type => {
          if (type.getText() === 'React.Component') {
            result = true;
          }
        });
      }
    });
  }

  return result;
}

function isUpperCase(str: string): boolean {
  return str === str.toUpperCase();
}

and finally we should put our new rule to tsling.json:

// Custom rules
"ts-loader": true,
"my-react-component": true

So such code as

App extends React.Component

will result in:

enter image description here

I also created ejected react-ts application where you can try it.

Update

I guess tracking class names in grandparents won't be a trivial task

Indeed we can handle inheritance. To do that we will need create rule extended from class Lint.Rules.TypedRule to have access to TypeChecker:

myReactComponentRule.ts

import * as ts from 'typescript';
import * as Lint from 'tslint';

export class Rule extends Lint.Rules.TypedRule {
  /* tslint:disable:object-literal-sort-keys */
  static metadata: Lint.IRuleMetadata = {
    ruleName: 'my-react-component',
    description: 'Enforces PascalCased React component class.',
    rationale: 'Makes it easy to differentiate classes from regular variables at a glance.',
    optionsDescription: 'Not configurable.',
    options: null,
    optionExamples: [true],
    type: 'style',
    typescriptOnly: false,
  };
  /* tslint:enable:object-literal-sort-keys */

  static FAILURE_STRING = (className: string) =>
    `React component ${className} must be PascalCased and prefixed by Component`;

  static validate(name: string): boolean {
    return isUpperCase(name[0]) && !name.includes('_') && name.endsWith('Component');
  }

  applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): Lint.RuleFailure[] {
    return this.applyWithFunction(sourceFile, walk, undefined, program.getTypeChecker());
  }
}

function walk(ctx: Lint.WalkContext<void>, tc: ts.TypeChecker) {
  return ts.forEachChild(ctx.sourceFile, function cb(node: ts.Node): void {
    if (
        isClassLikeDeclaration(node) && node.name !== undefined &&
        containsType(tc.getTypeAtLocation(node), isReactComponentType) &&
        !Rule.validate(node.name!.text)) {
      ctx.addFailureAtNode(node.name!, Rule.FAILURE_STRING(node.name!.text));
    }

    return ts.forEachChild(node, cb);
  });
}
/* tslint:disable:no-any */
function containsType(type: ts.Type, predicate: (symbol: any) => boolean): boolean {
  if (type.symbol !== undefined && predicate(type.symbol)) {
    return true;
  }

  const bases = type.getBaseTypes();
  return bases && bases.some((t) => containsType(t, predicate));
}

function isReactComponentType(symbol: any) {
  return symbol.name === 'Component' && symbol.parent && symbol.parent.name === 'React';
}
/* tslint:enable:no-any */

function isClassLikeDeclaration(node: ts.Node): node is ts.ClassLikeDeclaration {
  return node.kind === ts.SyntaxKind.ClassDeclaration ||
    node.kind === ts.SyntaxKind.ClassExpression;
}

function isUpperCase(str: string): boolean {
  return str === str.toUpperCase();
}

See also commit:

  • https://github.com/alexzuza/react-ts-test/commit/98ea77fa5a1f4fc73c53a055deb6960a3e7f3afe
like image 62
yurzui Avatar answered Oct 16 '22 11:10

yurzui


This is lot easier to do in eslint. The custom plugin is a lot less complex. So I created a plugin showcasing the same. To test the plugin I created the below file

import React from "react"

class ABCComponent extends React.Component {

}

class ABC2component extends React.Component {

}

class TestComponent {

}


class FooBarComponent extends React.Component {

}

class fooBazComponent extends React.Component {

}

class FooBazing extends React.Component {

}

And then ran the plugin on the same

Plugin results

I followed the below guides while writing the plugin

https://flexport.engineering/writing-custom-lint-rules-for-your-picky-developers-67732afa1803

https://www.kenneth-truyers.net/2016/05/27/writing-custom-eslint-rules/

https://eslint.org/docs/developer-guide/working-with-rules

The final code I come up with was below for the rules

/**
 * @fileoverview Check that proper naming convention is followed for React components
 * @author Tarun Lalwani
 */
"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
var toPascalCase = require('to-pascal-case');

module.exports = {
    meta: {
        docs: {
            description: "Check that proper naming convention is followed for React components",
            category: "Fill me in",
            recommended: false
        },
        fixable: "code",  // or "code" or "whitespace"
        schema: [
            // fill in your schema
        ]
    },

    create: function(context) {

        // variables should be defined here

        //----------------------------------------------------------------------
        // Helpers
        //----------------------------------------------------------------------

        // any helper functions should go here or else delete this section

        //----------------------------------------------------------------------
        // Public
        //----------------------------------------------------------------------

        return {

            ClassDeclaration: function(node) {
                var isReactComponent = false;
                if (node.superClass && node.superClass && node.superClass)
                {
                    if (node.superClass.object && node.superClass.object.name == 'React' && node.superClass.property.name === 'Component')
                        {
                            isReactComponent = true;
                        }
                    else if (node.superClass && node.superClass.name === 'Component') {
                        // if you want to suppot extends Component instead of just React.Component
                        isReactComponent = true;
                    }
                }

                if (isReactComponent) {
                    var className = node.id.name;
                    if (className[0] !== className[0].toUpperCase() || !className.endsWith("Component"))
                         context.report({
                            node: node, 
                            message: "Please use Proper case for the React Component class - {{identifier}}",
                            data: {
                                identifier: className
                            }, fix: (fixer) => {
                                var newClassName = className.toLowerCase().replace('component', '') + 'Component';
                                newClassName = toPascalCase(newClassName);
                                return fixer.replaceTextRange(node.id.range, newClassName)
                            }
                        });

                }
            }

        };
    }
};

The key is to understand the AST Tree, which I did using astexplorer. Rest code is quite self explanatory.

I have hosted the plugin on below repo in case you want to give it a short directly

https://github.com/tarunlalwani/eslint-plugin-react-class-naming

Install the plugin using below command

npm i tarunlalwani/eslint-plugin-react-class-naming#master

Then add it to your .eslintrc

{
    "plugins": [
       "react-class-naming"
    ]
}

Then add the rules in .eslintrc

"rules": {
   "react-class-naming/react-classnaming-convention": ["error"],
   ....
}
like image 26
Tarun Lalwani Avatar answered Oct 16 '22 11:10

Tarun Lalwani