UNPKG

eslint-plugin-html

Version:

A ESLint plugin to lint and fix inline scripts contained in HTML files.

335 lines (288 loc) 9.97 kB
"use strict" const path = require("path") const extract = require("./extract") const utils = require("./utils") const splatSet = utils.splatSet const getSettings = require("./settings").getSettings const PREPARE_RULE_NAME = "__eslint-plugin-html-prepare" const LINTER_ISPATCHED_PROPERTY_NAME = "__eslint-plugin-html-verify-function-is-patched" // 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 const needles = [ path.join("lib", "linter", "linter.js"), // ESLint 6+ path.join("lib", "linter.js"), // ESLint 5- ] iterateESLintModules(patch) function getLinterFromModule(moduleExports) { return moduleExports.Linter ? moduleExports.Linter // ESLint 6+ : moduleExports // ESLint 5- } function getModuleFromRequire() { return getLinterFromModule(require("eslint/lib/linter")) } function getModuleFromCache(key) { if (!needles.some((needle) => key.endsWith(needle))) return const module = require.cache[key] if (!module || !module.exports) return const Linter = getLinterFromModule(module.exports) if ( typeof Linter === "function" && typeof Linter.prototype.verify === "function" ) { return Linter } } 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. fn(getModuleFromRequire()) return } let found = false for (const key in require.cache) { const Linter = getModuleFromCache(key) if (Linter) { fn(Linter) found = true } } if (!found) { let eslintPath, eslintVersion try { eslintPath = require.resolve("eslint") } catch (e) { eslintPath = "(not found)" } try { eslintVersion = require("eslint/package.json").version } catch (e) { eslintVersion = "n/a" } const parentPaths = (module) => module ? [module.filename].concat(parentPaths(module.parent)) : [] throw new Error( `eslint-plugin-html error: It seems that eslint is not loaded. If you think this is a bug, please file a report at https://github.com/BenoitZugmeyer/eslint-plugin-html/issues In the report, please include *all* those informations: * ESLint version: ${eslintVersion} * ESLint path: ${eslintPath} * Plugin version: ${require("../package.json").version} * Plugin inclusion paths: ${parentPaths(module).join(", ")} * NodeJS version: ${process.version} * CLI arguments: ${JSON.stringify(process.argv)} * Content of your lock file (package-lock.json or yarn.lock) or the output of \`npm list\` * How did you run ESLint (via the command line? an editor plugin?) * The following stack trace: ${new Error().stack.slice(10)} ` ) } } function getMode(pluginSettings, filenameOrOptions) { const filename = typeof filenameOrOptions === "object" ? filenameOrOptions.filename : filenameOrOptions const extension = path.extname(filename || "") if (pluginSettings.htmlExtensions.indexOf(extension) >= 0) { return "html" } if (pluginSettings.xmlExtensions.indexOf(extension) >= 0) { return "xml" } } function patch(Linter) { const verifyMethodName = Linter.prototype._verifyWithoutProcessors ? "_verifyWithoutProcessors" // ESLint 6+ : "verify" // ESLint 5- const verify = Linter.prototype[verifyMethodName] // ignore if verify function is already been patched sometime before if (Linter[LINTER_ISPATCHED_PROPERTY_NAME] === true) { return } Linter[LINTER_ISPATCHED_PROPERTY_NAME] = true Linter.prototype[verifyMethodName] = function ( textOrSourceCode, config, filenameOrOptions, saveState ) { if (typeof config.extractConfig === "function") { return verify.call(this, textOrSourceCode, config, filenameOrOptions) } const pluginSettings = getSettings(config.settings || {}) const mode = getMode(pluginSettings, filenameOrOptions) if (!mode || typeof textOrSourceCode !== "string") { return verify.call( this, textOrSourceCode, config, filenameOrOptions, saveState ) } const extractResult = extract( textOrSourceCode, pluginSettings.indent, mode === "xml", pluginSettings.isJavaScriptMIMEType ) const messages = [] if (pluginSettings.reportBadIndent) { messages.push( ...extractResult.badIndentationLines.map((line) => ({ message: "Bad line indentation.", line, column: 1, ruleId: "(html plugin)", severity: pluginSettings.reportBadIndent, })) ) } // Save code parts parsed source code so we don't have to parse it twice const sourceCodes = new WeakMap() const verifyCodePart = (codePart, { prepare, ignoreRules } = {}) => { this.defineRule(PREPARE_RULE_NAME, (context) => { sourceCodes.set(codePart, context.getSourceCode()) return { Program() { if (prepare) { prepare(context) } }, } }) const localMessages = verify.call( this, sourceCodes.get(codePart) || String(codePart), Object.assign({}, config, { rules: Object.assign( { [PREPARE_RULE_NAME]: "error" }, !ignoreRules && config.rules ), }), ignoreRules && typeof filenameOrOptions === "object" ? Object.assign({}, filenameOrOptions, { reportUnusedDisableDirectives: false, }) : filenameOrOptions, saveState ) messages.push( ...remapMessages(localMessages, extractResult.hasBOM, codePart) ) } const parserOptions = config.parserOptions || {} if (parserOptions.sourceType === "module") { for (const codePart of extractResult.code) { verifyCodePart(codePart) } } else { verifyWithSharedScopes(extractResult.code, verifyCodePart, parserOptions) } messages.sort((ma, mb) => ma.line - mb.line || ma.column - mb.column) return messages } } function verifyWithSharedScopes(codeParts, verifyCodePart, parserOptions) { // First pass: collect needed globals and declared globals for each script tags. const firstPassValues = [] for (const codePart of codeParts) { verifyCodePart(codePart, { prepare(context) { const globalScope = context.getScope() // See https://github.com/eslint/eslint/blob/4b267a5c8a42477bb2384f33b20083ff17ad578c/lib/rules/no-redeclare.js#L67-L78 let scopeForDeclaredGlobals if ( parserOptions.ecmaFeatures && parserOptions.ecmaFeatures.globalReturn ) { scopeForDeclaredGlobals = globalScope.childScopes[0] } else { scopeForDeclaredGlobals = globalScope } firstPassValues.push({ codePart, exportedGlobals: globalScope.through.map( (node) => node.identifier.name ), declaredGlobals: scopeForDeclaredGlobals.variables.map( (variable) => variable.name ), }) }, ignoreRules: true, }) } // Second pass: declare variables for each script scope, then run eslint. for (let i = 0; i < firstPassValues.length; i += 1) { verifyCodePart(firstPassValues[i].codePart, { prepare(context) { const exportedGlobals = splatSet( firstPassValues .slice(i + 1) .map((nextValues) => nextValues.exportedGlobals) ) for (const name of exportedGlobals) context.markVariableAsUsed(name) const declaredGlobals = splatSet( firstPassValues .slice(0, i) .map((previousValues) => previousValues.declaredGlobals) ) const scope = context.getScope() scope.through = scope.through.filter((variable) => { return !declaredGlobals.has(variable.identifier.name) }) }, }) } } function remapMessages(messages, hasBOM, codePart) { const newMessages = [] const bomOffset = hasBOM ? -1 : 0 for (const message of messages) { const location = codePart.originalLocation({ line: message.line, // eslint-plugin-eslint-comments is raising message with column=0 to bypass ESLint ignore // comments. Since messages are already ignored at this time, just reset the column to a valid // number. See https://github.com/BenoitZugmeyer/eslint-plugin-html/issues/70 column: message.column || 1, }) // Ignore messages if they were in transformed code if (location) { Object.assign(message, location) message.source = codePart.getOriginalLine(location.line) // Map fix range if (message.fix && message.fix.range) { message.fix.range = [ codePart.originalIndex(message.fix.range[0]) + bomOffset, // The range end is exclusive, meaning it should replace all characters with indexes from // start to end - 1. We have to get the original index of the last targeted character. codePart.originalIndex(message.fix.range[1] - 1) + 1 + bomOffset, ] } // Map end location if (message.endLine && message.endColumn) { const endLocation = codePart.originalLocation({ line: message.endLine, column: message.endColumn, }) if (endLocation) { message.endLine = endLocation.line message.endColumn = endLocation.column } } newMessages.push(message) } } return newMessages }