Creating a Linter plugin to detect non-internationalised strings in codebase

What is a Linter?

A Linter is a tool that analyses source code statically and alerts developers of any potential programming syntax errors, stylistic errors, or suspicious constructs.

We can choose to run a linter at any stage of a development pipeline.

ESLint

ESLint is an example of a linter.

As quoted on their website, EsLint is a pluggable linter that works on ECMAScript/JavaScript code.

  • ESLint has a pluggable architecture where every single rule is a plugin and you can add more at runtime.

To understand the basics of linting with ESLint, you could check out this entertaining You tube video.

What’s the deal with internationalised strings?

When a product is to be rolled out to different markets, most of the time, the strings to be displayed on the product should be displayed in the market’s specific language. For instance, an app launched in the Chinese market will need to have Chinese characters. On the other hand, if an app is launched in the Russian market, it will need to contain Russian characters.

Does this mean there is a need to maintain separate codebases, one for each of the market that the app is deployed in, so that the strings being displayed in the front end is in the correct language? It shouldn’t be the case.

To solve this problem of strings translation, teams will need to do some work to internationalise their product.

Internationalisation (i18n) is the process of generalising a product so it can handle multiple languages and cultural conventions without needing to re design. It allows for new products to be built to work for any international market.

Refer to the link here for some guidelines on internationalise strings:

One of the best practices for a product to be internationalised is to avoid hard coded UI strings in codebase and instead, to wrap them with gettext functions.

<div> {gettext("string to be translated")} </div>

Refer to the following links on what gettext functions are:

The gettext function takes in a string to be translated. In most scenarios, it will be in English.

After the string has been translated by linguists, the translated string will be placed in PO files.

During runtime, when the client loads the app page, a javascript object containing the translations will be made available to the client by the server. The gettext function will read that javascript object and return the right translation for the specific string. Therefore eventually, the translated string for the selected locale will be displayed to users.

For example, in code we will see: <div> {gettext("thanks")} </div>

In run time, it will be <div>{'merci'}</div>

Conclusion:

Internationalised strings should be wrapped in gettext functions. We want to ensure that all developers follow this practice in their development and we do this by using a Linter to flag warnings in the development environment if it is detected that developers fail to abide by this practice.

Detect hardcoded UI strings ESLint rule with eslint plugin

The example I will share in this post will be targeted at TypeScript projects and I will be using an external library: @typescript-eslint to help (Because the default Eslint is for JavaScript). I will create a Eslint plugin which will contain the rule for detecting UI strings. This ESlint will need to be imported as a dependency by projects that want to use it.

Note: This is not really a guide for anyone who is completely new to eslint plugin as I will jump straight into the code and skip any set up necessary. If you are new, you could refer to this other tutorial which I found to be very helpful before coming back.

Another very helpful resource is:

An ESLint rule contains 2 main parts:

  • meta: an object where we will specify the usage of our rule.

Here is the code (Excluding some unimportant helper code) for this rule

import { TSESTree } from '@typescript-eslint/experimental-utils';
import { RuleContext } from '@typescript-eslint/experimental-utils/dist/ts-eslint/Rule';
const ruleDocumentationUrl = '';

const messages = {
needI18n: 'i18n: UI strings should be wrapped with gettext functions',
};
function createRule(ruleDocumentationUrl: string): ReturnType<typeof ESLintUtils.RuleCreator> {
return ESLintUtils.RuleCreator((_) => ruleDocumentationUrl);
}
// Check for hardcoded inline JSX strings that are non-whitespace
function findHardcodedInlineJSXString(
node: TSESTree.Literal | TSESTree.JSXText | TSESTree.TemplateElement
): boolean {
if (typeof node.value === 'string') {
const text = stripNonAlphanumericChars(node.value).trim();
if (hasAlphaNumericChars(text)) {
return true;
}
}
return false;
}

// Check for hardcoded inline template strings that are non-whitespace
function findHardCodedInlineTemplateString(
node: TSESTree.Literal | TSESTree.JSXText | TSESTree.TemplateElement
): boolean {
if (node.type === 'TemplateElement') {
const text = stripNonAlphanumericChars((node as TSESTree.TemplateElement).value.raw).trim();
if (hasAlphaNumericChars(text)) {
return true;
}
}
return false;
}
const rule = createRule(ruleDocumentationUrl)({
name: 'no-hardcoded-ui-string',
meta: {
type: 'suggestion',
docs: {
description:
'UI strings should not be hardcoded and should be wrapped in i18n gettext functions',
category: 'Best Practices',
recommended: 'warn',
},
schema: [],
messages,
},
defaultOptions: [],
create<TOptions extends readonly unknown[], TMessageIds extends string>(
context: Readonly<RuleContext<TMessageIds, TOptions>>
) {
/**
* Reports an error for AST nodes that are identified as hardcoded UI strings
*
@param node the node to be evaluated
*
@private
*/
function handleHardcodedString(
node: TSESTree.Literal | TSESTree.JSXText | TSESTree.TemplateElement
): void {

// Perform validation and make send context report
if (findHardcodedInlineJSXString(node)) {
context.report({
node: node,
messageId: 'needI18n' as TMessageIds,

});
}
if (findHardCodedInlineTemplateString(node)) {
context.report({
node: node,
messageId: 'needI18n' as TMessageIds,

});
}
}

return {
// JSXText is present for backwards compatibility with older versions of
// @typescript-eslint/parser where they used JSXText instead of Literal
// for text literals, which does not follow the JSX AST spec.
':matches(JSXElement, JSXFragment) > :matches(Literal, JSXText)': handleHardcodedString,
':matches(JSXElement, JSXFragment) > JSXExpressionContainer > :matches(Literal, JSXText)': handleHardcodedString,
':matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral > TemplateElement': handleHardcodedString,

};
},
});
export = rule;

Dissecting the code (Bottom to up)

As seen, this section matches selectors with functions. When elements that is parsed matches the selectors defined here, the respective mapped functions will be triggered. In this case, we only have 1 function handleHardcodedString which will be triggered if hardcoded strings enclosed by JSXElements have been detected.

return {
// JSXText is present for backwards compatibility with older versions of
// @typescript-eslint/parser where they used JSXText instead of Literal
// for text literals, which does not follow the JSX AST spec.
':matches(JSXElement, JSXFragment) > :matches(Literal, JSXText)': handleHardcodedString,
':matches(JSXElement, JSXFragment) > JSXExpressionContainer > :matches(Literal, JSXText)': handleHardcodedString,
':matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral > TemplateElement': handleHardcodedString,

};

Elements that matches this selectors would be:

<h1>Page Not Found</h1><>Page Not Found</>

To find out more about what selectors are as well as how to construct them, refer to:

I also found this website to be helpful in helping me to visualise the AST nodes and understand how to construct the selectors.

Inside the function handleHardcodedString, further validation on the string’s content will be carried out where non alphabet and numeric characters are removed and checked to see if there are any remaining characters. If yes, it means that it is definitely a hardcoded string. Then, context.report() will publish a warning or error.

  • stripNonAlphanumericChars and hasAlphaNumericChars are helper methods whose implementations are not shown.
/**
* Reports an error for AST nodes that are identified as hardcoded UI strings
*
@param node the node to be evaluated
*
@private
*/
function handleHardcodedString(
node: TSESTree.Literal | TSESTree.JSXText | TSESTree.TemplateElement
): void {

// Perform validation and make send context report
if (findHardcodedInlineJSXString(node)) {
context.report({
node: node,
messageId: 'needI18n' as TMessageIds,

});
}
if (findHardCodedInlineTemplateString(node)) {
context.report({
node: node,
messageId: 'needI18n' as TMessageIds,

});
}
}
// Check for hardcoded inline JSX strings that are non-whitespace
function findHardcodedInlineJSXString(
node: TSESTree.Literal | TSESTree.JSXText | TSESTree.TemplateElement
): boolean {
if (typeof node.value === 'string') {
const text = stripNonAlphanumericChars(node.value).trim();
if (hasAlphaNumericChars(text)) {
return true;
}
}
return false;
}

// Check for hardcoded inline template strings that are non-whitespace
function findHardCodedInlineTemplateString(
node: TSESTree.Literal | TSESTree.JSXText | TSESTree.TemplateElement
): boolean {
if (node.type === 'TemplateElement') {
const text = stripNonAlphanumericChars((node as TSESTree.TemplateElement).value.raw).trim();
if (hasAlphaNumericChars(text)) {
return true;
}
}
return false;
}

Testing

ESLint provides the RuleTester utility to make it easy to write tests for rules.

Here is the tests for this rule:

import { ESLintUtils } from '@typescript-eslint/experimental-utils';
import { ParserOptions } from '@typescript-eslint/experimental-utils/dist/ts-eslint';

import rule from '../../../src/rules/no-hardcoded-ui-string';

const RuleTester = ESLintUtils.RuleTester;

const parserOptions: Readonly<ParserOptions> = {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
};

const ruleTester = new RuleTester({
parser: '@typescript-eslint/parser',
parserOptions,
});

ruleTester.run('no-hardcoded-ui-string', rule, {
valid: [

"<h1>{ gt.gettext('Page Not Found') }</h1>",
"<div>{ gt.gettext('Page Not Found') }</div>",
"<>{ gt.gettext('Page Not Found') }</>",
// Add as many tests as you want
],
invalid: [
// Basic Tests
{
code: '<h1>Page Not Found</h1>',
errors: [
{
messageId: 'needI18n',
},
],
filename: 'SomeComponent.tsx',
},
// Add as many tests as you want
],
});

The end! Hope this short article is helpful in any way to you :)

If you are keen, you can also check out another related article.

This is a repository of my thoughts on my personal life, my random interests & notes taken down as I navigate my way through the tech world!