index.js 6.87 KB
"use strict"

const path = require("path")
const extract = require("./extract")

const defaultHTMLExtensions = [
  ".erb",
  ".handlebars",
  ".hbs",
  ".htm",
  ".html",
  ".mustache",
  ".nunjucks",
  ".php",
  ".tag",
  ".twig",
  ".vue",
  ".we",
]

const defaultXMLExtensions = [
  ".xhtml",
  ".xml",
]

// Disclaimer:
//
// This is not a long term viable solution. ESLint needs to improve its processor API to
// provide access to the configuration before actually preprocess files, but it's not
// planed yet. This solution is quite ugly but shouldn't alter eslint process.
//
// Related github issues:
// https://github.com/eslint/eslint/issues/3422
// https://github.com/eslint/eslint/issues/4153

iterateESLintModules(patch)

function iterateESLintModules(fn) {
  if (!require.cache || Object.keys(require.cache).length === 0) {
    // Jest is replacing the node "require" function, and "require.cache" isn't available here.
    return fn({
      eslint: require("eslint/lib/eslint"),
      SourceCodeFixer: require("eslint/lib/util/source-code-fixer"),
    })
  }

  let found = false
  const needle = path.join("lib", "eslint.js")
  for (const key in require.cache) {
    if (key.endsWith(needle)) {
      const sourceCodeFixerKey = path.join(key, "..", "util", "source-code-fixer.js")

      const eslint = require.cache[key]
      const SourceCodeFixer = require.cache[sourceCodeFixerKey]

      if (eslint.exports && typeof eslint.exports.verify === "function" &&
        SourceCodeFixer && SourceCodeFixer.exports) {
        fn({ eslint: eslint.exports, SourceCodeFixer: SourceCodeFixer.exports })
        found = true
      }
    }
  }

  if (!found) {
    throw new Error("eslint-plugin-html error: It seems that eslint is not loaded. " +
                    "If you think it is a bug, please file a report at " +
                    "https://github.com/BenoitZugmeyer/eslint-plugin-html/issues")
  }
}

function filterOut(array, excludeArray) {
  if (!excludeArray) return array
  return array.filter((item) => excludeArray.indexOf(item) < 0)
}

function compileRegExp(re) {
  const tokens = re.split("/")
  if (tokens.shift()) { // Ignore first token
    throw new Error(`Invalid regexp: ${re}`)
  }
  const flags = tokens.pop()
  return new RegExp(tokens.join("/"), flags)
}

function getPluginSettings(settings) {

  const htmlExtensions = settings["html/html-extensions"] ||
      filterOut(defaultHTMLExtensions, settings["html/xml-extensions"])

  const xmlExtensions = settings["html/xml-extensions"] ||
      filterOut(defaultXMLExtensions, settings["html/html-extensions"])

  let reportBadIndent
  switch (settings["html/report-bad-indent"]) {
    case undefined: case false: case 0: case "off":
      reportBadIndent = 0
      break
    case true: case 1: case "warn":
      reportBadIndent = 1
      break
    case 2: case "error":
      reportBadIndent = 2
      break
    default:
      throw new Error("Invalid value for html/report-bad-indent, " +
        "expected one of 0, 1, 2, \"off\", \"warn\" or \"error\"")
  }

  const parsedIndent = /^(\+)?(tab|\d+)$/.exec(settings["html/indent"])
  const indent = parsedIndent && {
    relative: parsedIndent[1] === "+",
    spaces: parsedIndent[2] === "tab" ? "\t" : " ".repeat(parsedIndent[2]),
  }

  const javaScriptMIMETypes = settings["html/javascript-mime-types"] ?
    (
      Array.isArray(settings["html/javascript-mime-types"]) ?
        settings["html/javascript-mime-types"] :
        [settings["html/javascript-mime-types"]]
    ).map((s) => s.startsWith("/") ? compileRegExp(s) : s) :
    [/^(application|text)\/(x-)?(javascript|babel|ecmascript-6)$/i]

  function isJavaScriptMIMEType(type) {
    return javaScriptMIMETypes.some((o) => typeof o === "string" ? type === o : o.test(type))
  }

  return {
    htmlExtensions,
    xmlExtensions,
    indent,
    reportBadIndent,
    isJavaScriptMIMEType,
  }
}

function patch(modules) {
  const eslint = modules.eslint
  const SourceCodeFixer = modules.SourceCodeFixer

  const sourceCodeForMessages = new WeakMap()

  const verify = eslint.verify
  eslint.verify = function (textOrSourceCode, config, filenameOrOptions, saveState) {
    const localVerify = (code) => verify.call(this, code, config, filenameOrOptions, saveState)

    let messages
    const filename = typeof filenameOrOptions === "object" ?
      filenameOrOptions.filename :
      filenameOrOptions
    const extension = path.extname(filename || "")

    const pluginSettings = getPluginSettings(config.settings || {})
    const isHTML = pluginSettings.htmlExtensions.indexOf(extension) >= 0
    const isXML = !isHTML && pluginSettings.xmlExtensions.indexOf(extension) >= 0

    if (typeof textOrSourceCode === "string" && (isHTML || isXML)) {
      const currentInfos = extract(
        textOrSourceCode,
        pluginSettings.indent,
        isXML,
        pluginSettings.isJavaScriptMIMEType
      )

      messages = remapMessages(
        localVerify(String(currentInfos.code)),
        currentInfos.code,
        pluginSettings.reportBadIndent,
        currentInfos.badIndentationLines
      )
      sourceCodeForMessages.set(messages, textOrSourceCode)
    }
    else {
      messages = localVerify(textOrSourceCode)
    }

    return messages
  }

  const applyFixes = SourceCodeFixer.applyFixes
  SourceCodeFixer.applyFixes = function (sourceCode, messages) {
    const originalSourceCode = sourceCodeForMessages.get(messages)
    // The BOM is always included in the HTML, which is removed by the extract process
    return applyFixes.call(
      this,
      originalSourceCode === undefined ? sourceCode : { text: originalSourceCode, hasBOM: false },
      messages
    )
  }

}

function remapMessages(messages, code, reportBadIndent, badIndentationLines) {
  const newMessages = []

  for (const message of messages) {
    const location = code.originalLocation(message)

    // Ignore messages if they were in transformed code
    if (location) {
      Object.assign(message, location)

      // Map fix range
      if (message.fix && message.fix.range) {
        message.fix.range = [
          code.originalIndex(message.fix.range[0]),
          code.originalIndex(message.fix.range[1]),
        ]
      }

      // Map end location
      if (message.endLine && message.endColumn) {
        const endLocation = code.originalLocation({
          line: message.endLine,
          column: message.endColumn,
        })
        if (endLocation) {
          message.endLine = endLocation.line
          message.endColumn = endLocation.column
        }
      }

      newMessages.push(message)
    }
  }

  if (reportBadIndent) {
    badIndentationLines.forEach((line) => {
      newMessages.push({
        message: "Bad line indentation.",
        line,
        column: 1,
        ruleId: "(html plugin)",
        severity: reportBadIndent === true ? 2 : reportBadIndent,
      })
    })
  }

  newMessages.sort((ma, mb) => {
    return ma.line - mb.line || ma.column - mb.column
  })

  return newMessages
}