ESLint Unescaped Quotes Plugin

I build a custom ESLint plugin for my personal website to prevent using unescaped single or double quotes in JSX and instead use “smart quotes” which are handled properly and also display consistently with the rest of my website. The plugin is surprisingly simple, and hopefully helps relive your fears about writing your own simple plugins like this one!

Explaining all the details of the plugin would take a bit of time, so I’m just going to post the source code for the plugin for you to explore. It’s mostly self explanatory, so you should be able to understand how it works pretty easily.

// @ts-check

const entities = [
  { alternatives: ["", ""], char: '"' },
  { alternatives: ["", ""], char: "'" },
]

/**
 * @param {import('eslint').Rule.Node} node
 * @returns {boolean} Whether or not the node if a JSX element or fragment.
 */
function isJSX(node) {
  return node && ["JSXElement", "JSXFragment"].indexOf(node.type) >= 0
}

/**
 * @param {import("eslint").Rule.RuleContext} context
 * @param {import("eslint").Rule.Node} node
 */
function reportUnescaped(context, node) {
  if (!node.loc) return

  // HTML entities are already escaped in node.value (as well as node.raw),
  // so pull the raw text from context.sourceCode
  for (let i = node.loc.start.line; i <= node.loc.end.line; i++) {
    let rawLine = context.sourceCode.lines[i - 1]
    const start = i === node.loc.start.line ? node.loc.start.column : 0
    const end =
      i === node.loc.end.line ? node.loc.end.column : rawLine.length

    rawLine = rawLine.slice(start, end)

    for (const entity of entities) {
      for (let index = 0; index < rawLine.length; index++) {
        if (rawLine[index] === entity.char) {
          context.report({
            data: { entity: entity.char },
            loc: { column: start + index, line: i },
            messageId: "unescapedEntity",
            node,
            suggest: entity.alternatives.map((alt) => ({
              data: {
                alt,
                entity: entity.char,
              },
              fix: (fixer) => {
                const rangeStart = context.sourceCode.getIndexFromLoc({
                  column: start + index,
                  line: i,
                })

                return fixer.replaceTextRange(
                  [rangeStart, rangeStart + 1],
                  alt,
                )
              },
              messageId: "unescapedEntitySuggestion",
            })),
          })
        }
      }
    }
  }
}

/** @type {import("eslint").Rule.RuleModule} */
const rule = {
  create(context) {
    return {
      /** @param {import("eslint").Rule.Node} node */
      "Literal, JSXText"(node) {
        if (isJSX(node.parent)) {
          reportUnescaped(context, node)
        }
      },
    }
  },
  meta: {
    fixable: "code",
    hasSuggestions: true,
    messages: {
      unescapedEntity: "HTML entity, {{entity}}, must be escaped.",
      unescapedEntitySuggestion: "Replace {{entity}} with {{alt}}.",
    },
    schema: [],
    type: "suggestion",
  },
}

module.exports = rule