UNPKG

eslint-plugin-mdx

Version:
750 lines (733 loc) 29 kB
import { __assign, __spreadArray } from 'tslib'; import { version } from 'eslint/package.json'; import { isJsxNode, arrayify, DEFAULT_EXTENSIONS, MARKDOWN_EXTENSIONS, last } from 'eslint-mdx'; import esLintNoUnusedExpressions from 'eslint/lib/rules/no-unused-expressions'; import path from 'path'; import vfile from 'vfile'; import { cosmiconfigSync } from 'cosmiconfig'; import remarkMdx from 'remark-mdx'; import remarkParse from 'remark-parse'; import remarkStringify from 'remark-stringify'; import unified from 'unified'; var base = { parser: 'eslint-mdx', plugins: ['mdx'], processor: 'mdx/remark', }; var codeBlocks = { parserOptions: { ecmaFeatures: { // Adding a "use strict" directive at the top of // every code block is tedious and distracting, so // opt into strict mode parsing without the // directive. impliedStrict: true, }, }, rules: { // The Markdown parser automatically trims trailing // newlines from code blocks. 'eol-last': 'off', // In code snippets and examples, these rules are often // counterproductive to clarity and brevity. 'no-undef': 'off', 'no-unused-expressions': 'off', 'no-unused-vars': 'off', 'padded-blocks': 'off', // Adding a "use strict" directive at the top of every // code block is tedious and distracting. The config // opts into strict mode parsing without the directive. strict: 'off', // The processor will not receive a Unicode Byte Order // Mark from the Markdown parser. 'unicode-bom': 'off', }, }; var getGlobals = function (sources, initialGlobals) { if (initialGlobals === void 0) { initialGlobals = {}; } return (Array.isArray(sources) ? sources : Object.keys(sources)).reduce(function (globals, source) { var _a; return Object.assign(globals, (_a = {}, _a[source] = false, _a)); }, initialGlobals); }; var rebass; try { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires rebass = require('rebass'); } catch (_a) { // `rebass`(or `reflexbox` actually) requires `react` as peerDependency, but not all projects using `mdx` are `React` based, so we fallback to hardcoded `rebass` Components here /* istanbul ignore next */ rebass = ['Box', 'Flex', 'Text', 'Heading', 'Link', 'Button', 'Image', 'Card']; } var overrides$1 = __assign(__assign({}, base), { globals: getGlobals(rebass, { React: false, }), rules: { 'lines-between-class-members': 0, 'react/jsx-no-undef': [ 2, { allowGlobals: true, }, ], 'react/react-in-jsx-scope': 0, } }); var minorVersion = +version.split('.').slice(0, 2).join('.'); var recommended = __assign(__assign({}, base), { rules: { 'mdx/no-jsx-html-comments': 2, 'mdx/no-unused-expressions': 2, 'mdx/remark': 1, 'no-unused-expressions': 0, } }); var OVERRIDES_AVAILABLE_VERSION = 6.4; // overrides in npm pkg is supported after v6.4.0 // istanbul ignore else if (minorVersion >= OVERRIDES_AVAILABLE_VERSION) { var overrides = [ { files: '*.mdx', extends: 'plugin:mdx/overrides', }, { files: '**/*.{md,mdx}/**', extends: 'plugin:mdx/code-blocks', }, ]; try { // eslint-disable-next-line node/no-extraneous-require require.resolve('prettier'); // eslint-disable-next-line node/no-extraneous-require require.resolve('eslint-plugin-prettier'); overrides.push({ files: '*.md', rules: { 'prettier/prettier': [ 2, { parser: 'markdown', }, ], }, }); } catch (_a) { } Object.assign(recommended, { overrides: overrides, }); } /* istanbul ignore file */ var configs = { base: base, 'code-blocks': codeBlocks, codeBlocks: codeBlocks, overrides: overrides$1, recommended: recommended, }; var noJsxHtmlComments = { meta: { type: 'problem', docs: { description: 'Forbid invalid html style comments in jsx block', category: 'SyntaxError', recommended: true, }, messages: { jsxHtmlComments: 'html style comments are invalid in jsx: {{ origin }}', }, fixable: 'code', }, create: function (context) { return { ExpressionStatement: function (node) { var invalidNodes = context.parserServices.JSXElementsWithHTMLComments; if (!isJsxNode(node.expression) || node.parent.type !== 'Program' || !invalidNodes || invalidNodes.length === 0) { return; } var invalidNode = invalidNodes.shift(); if (invalidNode.data.inline) { return; } var comments = invalidNode.data.comments; var _loop_1 = function (fixed, loc, origin) { context.report({ messageId: 'jsxHtmlComments', data: { origin: origin, }, loc: loc, node: node, fix: function (fixer) { return fixer.replaceTextRange([loc.start.offset, loc.end.offset], fixed); }, }); }; for (var _i = 0, comments_1 = comments; _i < comments_1.length; _i++) { var _a = comments_1[_i], fixed = _a.fixed, loc = _a.loc, origin = _a.origin; _loop_1(fixed, loc, origin); } }, }; }, }; /// <reference path="../../typings.d.ts" /> var noUnusedExpressions = __assign(__assign({}, esLintNoUnusedExpressions), { create: function (context) { var esLintRuleListener = esLintNoUnusedExpressions.create(context); return { ExpressionStatement: function (node) { if (isJsxNode(node.expression) && node.parent.type === 'Program') { return; } esLintRuleListener.ExpressionStatement(node); }, }; } }); var requirePkg = function (plugin, prefix, filePath) { if (filePath && /^\.\.?([/\\]|$)/.test(plugin)) { plugin = path.resolve(path.dirname(filePath), plugin); } prefix = prefix.endsWith('-') ? prefix : prefix + '-'; var packages = [ plugin, plugin.startsWith('@') ? plugin.replace('/', '/' + prefix) : prefix + plugin, ]; var error; for (var _i = 0, packages_1 = packages; _i < packages_1.length; _i++) { var pkg = packages_1[_i]; try { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires return require(pkg); } catch (err) { if (!error) { error = err; } } } throw error; }; var searchSync; var remarkProcessor = unified().use(remarkParse).freeze(); var getRemarkProcessor = function (searchFrom, isMdx) { if (!searchSync) { searchSync = cosmiconfigSync('remark', { packageProp: 'remarkConfig', }).search; } var result; try { result = searchSync(searchFrom); } catch (err) { // https://github.com/eslint/eslint/issues/11989 /* istanbul ignore if */ if (err.code !== 'ENOTDIR' || !/\d+_?\.[a-z]+$/.test(searchFrom)) { throw err; } try { result = searchSync(path.dirname(searchFrom)); } catch (_a) { /* istanbul ignore next */ throw err; } } /* istanbul ignore next */ var _b = ((result === null || result === void 0 ? void 0 : result.config) || {}), _c = _b.plugins, plugins = _c === void 0 ? [] : _c, settings = _b.settings; try { // disable this rule automatically since we already have a parser option `extensions` // eslint-disable-next-line node/no-extraneous-require plugins.push([require.resolve('remark-lint-file-extension'), false]); } catch (_d) { // just ignore if the package does not exist } var initProcessor = remarkProcessor().use({ settings: settings }).use(remarkStringify); if (isMdx) { initProcessor.use(remarkMdx); } return plugins .reduce(function (processor, pluginWithSettings) { var _a = arrayify(pluginWithSettings), plugin = _a[0], pluginSettings = _a.slice(1); return processor.use.apply(processor, __spreadArray([ /* istanbul ignore next */ typeof plugin === 'string' ? requirePkg(plugin, 'remark', result.filepath) : plugin], pluginSettings)); }, initProcessor) .freeze(); }; var remark$1 = { meta: { type: 'layout', docs: { description: 'Linter integration with remark plugins', category: 'Stylistic Issues', recommended: true, }, fixable: 'code', }, create: function (context) { var filename = context.getFilename(); var extname = path.extname(filename); var sourceCode = context.getSourceCode(); var options = context.parserOptions; var isMdx = DEFAULT_EXTENSIONS.concat(options.extensions || []).includes(extname); var isMarkdown = MARKDOWN_EXTENSIONS.concat(options.markdownExtensions || []).includes(extname); return { // eslint-disable-next-line sonarjs/cognitive-complexity Program: function (node) { /* istanbul ignore if */ if (!isMdx && !isMarkdown) { return; } var sourceText = sourceCode.getText(node); var remarkProcessor = getRemarkProcessor(filename, isMdx); var file = vfile({ path: filename, contents: sourceText, }); try { remarkProcessor.processSync(file); } catch (err) { /* istanbul ignore next */ if (!file.messages.includes(err)) { file.message(err).fatal = true; } } var _loop_1 = function (source, reason, ruleId, fatal, start, end) { // https://github.com/remarkjs/remark-lint/issues/65#issuecomment-220800231 /* istanbul ignore next */ var severity = fatal ? 2 : fatal == null ? 0 : 1; /* istanbul ignore if */ if (!severity) { return "continue"; } var message = { reason: reason, source: source, ruleId: ruleId, severity: severity, }; context.report({ // related to https://github.com/eslint/eslint/issues/14198 message: JSON.stringify(message), loc: { // ! eslint ast column is 0-indexed, but unified is 1-indexed start: __assign(__assign({}, start), { column: start.column - 1 }), end: __assign(__assign({}, end), { column: end.column - 1 }), }, node: node, fix: function (fixer) { /* istanbul ignore if */ if (start.offset == null) { return null; } var range = [ start.offset, /* istanbul ignore next */ end.offset == null ? start.offset + 1 : end.offset, ]; var partialText = sourceText.slice.apply(sourceText, range); var fixed = remarkProcessor.processSync(partialText).toString(); return fixer.replaceTextRange(range, /* istanbul ignore next */ partialText.endsWith('\n') ? fixed : fixed.slice(0, -1)); }, }); }; for (var _i = 0, _a = file.messages; _i < _a.length; _i++) { var _b = _a[_i], source = _b.source, reason = _b.reason, ruleId = _b.ruleId, fatal = _b.fatal, _c = _b.location, start = _c.start, end = _c.end; _loop_1(source, reason, ruleId, fatal, start, end); } }, }; }, }; /* istanbul ignore file */ var rules = { 'no-jsx-html-comments': noJsxHtmlComments, 'no-unused-expressions': noUnusedExpressions, noJsxHtmlComments: noJsxHtmlComments, noUnusedExpressions: noUnusedExpressions, remark: remark$1, }; /** * based of @link https://github.com/eslint/eslint-plugin-markdown/blob/main/lib/processor.js * * @fileoverview Processes Markdown files for consumption by ESLint. * @author Brandon Mills */ var UNSATISFIABLE_RULES = new Set([ 'eol-last', 'unicode-bom', // Code blocks will begin in the middle of Markdown files ]); var SUPPORTS_AUTOFIX = true; var blocksCache = {}; /** * Performs a depth-first traversal of the Markdown AST. * @param node A Markdown AST node. * @param callbacks A map of node types to callbacks. * @param parent The node's parent AST node. */ function traverse(node, callbacks, parent) { if (callbacks[node.type]) { callbacks[node.type](node, parent); } if (typeof node.children !== 'undefined') { var parent_1 = node; for (var _i = 0, _a = parent_1.children; _i < _a.length; _i++) { var child = _a[_i]; traverse(child, callbacks, parent_1); } } } /** * Converts leading HTML comments to JS block comments. * @param html The text content of an HTML AST node. * @returns JS block comment. */ function getComment(html) { var commentStart = '<!--'; var commentEnd = '-->'; var regex = /^(eslint\b|global\s)/u; if (html.slice(0, commentStart.length) !== commentStart || html.slice(-commentEnd.length) !== commentEnd) { return ''; } var comment = html.slice(commentStart.length, -commentEnd.length); if (!regex.test(comment.trim())) { return ''; } return comment; } // Before a code block, blockquote characters (`>`) are also considered // "whitespace". var leadingWhitespaceRegex = /^[>\s]*/u; /** * Gets the offset for the first column of the node's first line in the * original source text. * @param node A Markdown code block AST node. * @returns The offset for the first column of the node's first line. */ function getBeginningOfLineOffset(node) { return node.position.start.offset - node.position.start.column + 1; } /** * Gets the leading text, typically whitespace with possible blockquote chars, * used to indent a code block. * @param text The text of the file. * @param node A Markdown code block AST node. * @returns The text from the start of the first line to the opening * fence of the code block. */ function getIndentText(text, node) { return leadingWhitespaceRegex.exec(text.slice(getBeginningOfLineOffset(node)))[0]; } /** * When applying fixes, the postprocess step needs to know how to map fix ranges * from their location in the linted JS to the original offset in the Markdown. * Configuration comments and indentation trimming both complicate this process. * * Configuration comments appear in the linted JS but not in the Markdown code * block. Fixes to configuration comments would cause undefined behavior and * should be ignored during postprocessing. Fixes to actual code after * configuration comments need to be mapped back to the code block after * removing any offset due to configuration comments. * * Fenced code blocks can be indented by up to three spaces at the opening * fence. Inside of a list, for example, this indent can be in addition to the * indent already required for list item children. Leading whitespace inside * indented code blocks is trimmed up to the level of the opening fence and does * not appear in the linted code. Further, lines can have less leading * whitespace than the opening fence, so not all lines are guaranteed to have * the same column offset as the opening fence. * * The source code of a non-configuration-comment line in the linted JS is a * suffix of the corresponding line in the Markdown code block. There are no * differences within the line, so the mapping need only provide the offset * delta at the beginning of each line. * @param text The text of the file. * @param node A Markdown code block AST node. * @param comments List of configuration comment strings that will be * inserted at the beginning of the code block. * @returns A list of offset-based adjustments, where lookups are * done based on the `js` key, which represents the range in the linted JS, * and the `md` key is the offset delta that, when added to the JS range, * returns the corresponding location in the original Markdown source. */ function getBlockRangeMap(text, node, comments) { /* * The parser sets the fenced code block's start offset to wherever content * should normally begin (typically the first column of the line, but more * inside a list item, for example). The code block's opening fancy may be * further indented by up to three characters. If the code block has * additional indenting, the opening fence's first backtick may be up to * three whitespace characters after the start offset. */ var startOffset = getBeginningOfLineOffset(node); /* * Extract the Markdown source to determine the leading whitespace for each * line. */ var code = text.slice(startOffset, node.position.end.offset); var lines = code.split('\n'); /* * The parser trims leading whitespace from each line of code within the * fenced code block up to the opening fence's first backtick. The first * backtick's column is the AST node's starting column plus any additional * indentation. */ var baseIndent = getIndentText(text, node).length; /* * Track the length of any inserted configuration comments at the beginning * of the linted JS and start the JS offset lookup keys at this index. */ var commentLength = comments.reduce(function (len, comment) { return len + comment.length + 1; }, 0); /* * In case there are configuration comments, initialize the map so that the * first lookup index is always 0. If there are no configuration comments, * the lookup index will also be 0, and the lookup should always go to the * last range that matches, skipping this initialization entry. */ var rangeMap = [ { js: 0, md: 0, }, ]; // Start the JS offset after any configuration comments. var jsOffset = commentLength; /* * Start the Markdown offset at the beginning of the block's first line of * actual code. The first line of the block is always the opening fence, so * the code begins on the second line. */ var mdOffset = startOffset + lines[0].length + 1; /* * For each line, determine how much leading whitespace was trimmed due to * indentation. Increase the JS lookup offset by the length of the line * post-trimming and the Markdown offset by the total line length. */ for (var i = 0; i + 1 < lines.length; i++) { var line = lines[i + 1]; var leadingWhitespaceLength = leadingWhitespaceRegex.exec(line)[0].length; // The parser trims leading whitespace up to the level of the opening // fence, so keep any additional indentation beyond that. var trimLength = Math.min(baseIndent, leadingWhitespaceLength); rangeMap.push({ js: jsOffset, // Advance `trimLength` character from the beginning of the Markdown // line to the beginning of the equivalent JS line, then compute the // delta. md: mdOffset + trimLength - jsOffset, }); // Accumulate the current line in the offsets, and don't forget the // newline. mdOffset += line.length + 1; jsOffset += line.length - trimLength + 1; } return rangeMap; } var LANGUAGES_MAPPER = { javascript: 'js', javascriptreact: 'jsx', typescript: 'ts', typescriptreact: 'tsx', markdown: 'md', mdown: 'md', mkdn: 'md', }; /** * get short language * @param lang original language * @returns short language */ function getShortLang(lang) { var language = last(lang.split(/\s/u)[0].split('.')).toLowerCase(); return LANGUAGES_MAPPER[language] || language; } /** * Extracts lintable JavaScript code blocks from Markdown text. * @param text The text of the file. * @param filename The filename of the file * @returns Source code strings to lint. */ function preprocess(text, filename) { var ast = remarkProcessor.parse(text); var blocks = []; blocksCache[filename] = blocks; traverse(ast, { code: function (node, parent) { var comments = []; if (node.lang) { var index = parent.children.indexOf(node) - 1; var previousNode = parent.children[index]; while (previousNode && previousNode.type === 'html') { var comment = getComment(previousNode.value); if (!comment) { break; } if (comment.trim() === 'eslint-skip') { return; } comments.unshift("/*" + comment + "*/"); index--; previousNode = parent.children[index]; } blocks.push(__assign(__assign({}, node), { baseIndentText: getIndentText(text, node), comments: comments, rangeMap: getBlockRangeMap(text, node, comments) })); } }, }); return blocks.map(function (block, index) { return ({ filename: index + "." + getShortLang(block.lang), text: __spreadArray(__spreadArray([], block.comments), [block.value, '']).join('\n'), }); }); } /** * Creates a map function that adjusts messages in a code block. * @param block A code block. * @returns A function that adjusts messages in a code block. */ function adjustBlock(block) { var leadingCommentLines = block.comments.reduce(function (count, comment) { return count + comment.split('\n').length; }, 0); var blockStart = block.position.start.line; /** * Adjusts ESLint messages to point to the correct location in the Markdown. * @param message A message from ESLint. * @returns The same message, but adjusted to the correct location. */ return function adjustMessage(message) { var lineInCode = message.line - leadingCommentLines; if (lineInCode < 1) { return null; } var out = { line: lineInCode + blockStart, column: message.column + block.position.indent[lineInCode - 1] - 1, }; /* istanbul ignore else */ if (Number.isInteger(message.endLine)) { out.endLine = message.endLine - leadingCommentLines + blockStart; } var adjustedFix = {}; /* istanbul ignore else */ if (message.fix) { adjustedFix.fix = { range: message.fix.range.map(function (range) { // Advance through the block's range map to find the last // matching range by finding the first range too far and // then going back one. var i = 1; while (i < block.rangeMap.length && block.rangeMap[i].js <= range) { i++; } // Apply the mapping delta for this range. return range + block.rangeMap[i - 1].md; }), text: message.fix.text.replace(/\n/gu, "\n" + block.baseIndentText), }; } return __assign(__assign(__assign({}, message), out), adjustedFix); }; } /** * Excludes unsatisfiable rules from the list of messages. * @param message A message from the linter. * @returns True if the message should be included in output. */ function excludeUnsatisfiableRules(message) { return message && !UNSATISFIABLE_RULES.has(message.ruleId); } /** * Transforms generated messages for output. * @param messages An array containing one array of messages for each code block returned from `preprocess`. * @param filename The filename of the file * @returns A flattened array of messages with mapped locations. */ function postprocess(messages, filename) { var _a; var blocks = blocksCache[filename] || []; // eslint-disable-next-line unicorn/prefer-spread return (_a = []).concat.apply(_a, messages.map(function (group, i) { var block = blocks[i]; // non code block message, parsed by `eslint-mdx` for example if (!block) { return group; } var adjust = adjustBlock(block); return group.map(adjust).filter(excludeUnsatisfiableRules); })); } var markdown = { preprocess: preprocess, postprocess: postprocess, supportsAutofix: SUPPORTS_AUTOFIX, }; /** * based on @link https://github.com/sveltejs/eslint-plugin-svelte3/blob/master/src/processor_options.js */ var processorOptions = {}; // find Linter instance var linterPath = Object.keys(require.cache).find(function (path) { return /([/\\])eslint\1lib(?:\1linter){2}\.js$/.test(path); }); /* istanbul ignore if */ if (!linterPath) { throw new Error('Could not find ESLint Linter in require cache'); } // eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires var ESLinter = require(linterPath).Linter; // patch Linter#verify // eslint-disable-next-line @typescript-eslint/unbound-method var verify = ESLinter.prototype.verify; ESLinter.prototype.verify = function (code, config, options) { // fetch settings var settings = (config && (typeof config.extractConfig === 'function' ? config.extractConfig( /* istanbul ignore next */ typeof options === 'undefined' || typeof options === 'string' ? options : options.filename) : config).settings) || {}; processorOptions.lintCodeBlocks = settings['mdx/code-blocks'] === true; // call original Linter#verify return verify.call(this, code, config, options); }; var remark = { supportsAutofix: true, preprocess: function (text, filename) { if (!processorOptions.lintCodeBlocks) { return [text]; } return __spreadArray(__spreadArray([], markdown.preprocess(text, filename)), [text]); }, postprocess: function (lintMessages, filename) { return markdown.postprocess(lintMessages, filename).map(function (lintMessage) { var message = lintMessage.message, eslintRuleId = lintMessage.ruleId, eslintSeverity = lintMessage.severity; if (eslintRuleId !== 'mdx/remark') { return lintMessage; } var _a = JSON.parse(message), source = _a.source, ruleId = _a.ruleId, reason = _a.reason, severity = _a.severity; return __assign(__assign({}, lintMessage), { ruleId: source + "-" + ruleId, message: reason, severity: Math.max(eslintSeverity, severity) }); }); }, }; var processors = { markdown: markdown, remark: remark, }; export { base, codeBlocks, configs, getGlobals, getRemarkProcessor, noJsxHtmlComments, noUnusedExpressions, overrides$1 as overrides, processorOptions, processors, recommended, remark$1 as remark, remarkProcessor, requirePkg, rules };